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

Merge branch 'dev/5.0.x' into feat/83472-hide-page-tree

* dev/5.0.x: (73 commits)
  fix discriminant
  move variable definition to pagetree file
  apply dark color
  Improved test cocode
  apply isTarget style
  hide div tag when the validation is not applyed
  calc min-height
  apply min height to "grw-pagetree" class
  apply color in the appropriate file
  modify elements position
  Improved test code
  Added test & commented out socket feature temporally
  Switched process order
  WIP: need to write test code
  Fixed import
  Fixed lint
  Search worked
  Pagetree worked
  Normalized isOpen, isTarget
  apply i18n
  ...
Mao 4 лет назад
Родитель
Сommit
e67d80d4b8
69 измененных файлов с 1530 добавлено и 415 удалено
  1. 2 2
      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. 0 1
      packages/app/src/client/services/EditorContainer.js
  12. 1 1
      packages/app/src/components/Admin/App/AppSettingsPageContents.jsx
  13. 46 2
      packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  14. 5 2
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  15. 148 0
      packages/app/src/components/CompleteUserRegistrationForm.tsx
  16. 59 27
      packages/app/src/components/LoginForm.jsx
  17. 1 1
      packages/app/src/components/PageComment/CommentEditor.jsx
  18. 1 1
      packages/app/src/components/PageDeleteModal.tsx
  19. 13 14
      packages/app/src/components/PageEditor/EditorNavbarBottom.tsx
  20. 21 0
      packages/app/src/components/SearchPage.jsx
  21. 1 1
      packages/app/src/components/SearchPage/DeleteSelectedPageGroup.tsx
  22. 28 24
      packages/app/src/components/SearchPage/SearchControl.tsx
  23. 1 1
      packages/app/src/components/SearchPage/SearchOptionModal.tsx
  24. 7 2
      packages/app/src/components/SearchPage/SearchPageLayout.tsx
  25. 5 1
      packages/app/src/components/SearchPage/SearchResultList.tsx
  26. 31 16
      packages/app/src/components/SearchPage/SearchResultListItem.tsx
  27. 3 2
      packages/app/src/components/SearchPage/SortControl.tsx
  28. 1 1
      packages/app/src/components/Sidebar.tsx
  29. 1 1
      packages/app/src/components/Sidebar/CustomSidebar.tsx
  30. 5 7
      packages/app/src/components/Sidebar/PageTree.tsx
  31. 21 20
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  32. 12 4
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  33. 3 5
      packages/app/src/components/Sidebar/PageTree/PrivateLegacyPages.tsx
  34. 0 87
      packages/app/src/components/SlackNotification.jsx
  35. 67 0
      packages/app/src/components/SlackNotification.tsx
  36. 8 7
      packages/app/src/interfaces/search.ts
  37. 22 0
      packages/app/src/server/middlewares/inject-user-registration-order-by-token-middleware.ts
  38. 4 0
      packages/app/src/server/middlewares/inject-user-ui-settings-to-localvars.ts
  39. 1 1
      packages/app/src/server/models/named-query.ts
  40. 61 4
      packages/app/src/server/models/page.ts
  41. 67 0
      packages/app/src/server/models/user-registration-order.ts
  42. 10 0
      packages/app/src/server/models/user.js
  43. 12 0
      packages/app/src/server/routes/apiv3/index.js
  44. 17 0
      packages/app/src/server/routes/apiv3/page-listing.ts
  45. 3 0
      packages/app/src/server/routes/apiv3/security-setting.js
  46. 138 0
      packages/app/src/server/routes/apiv3/user-activation.ts
  47. 6 0
      packages/app/src/server/routes/index.js
  48. 114 0
      packages/app/src/server/routes/user-activation.ts
  49. 9 6
      packages/app/src/server/routes/user.js
  50. 6 0
      packages/app/src/server/service/config-loader.ts
  51. 92 20
      packages/app/src/server/service/page.js
  52. 7 1
      packages/app/src/server/service/search.ts
  53. 3 2
      packages/app/src/server/views/login.html
  54. 52 0
      packages/app/src/server/views/user-activation.html
  55. 9 0
      packages/app/src/stores/editor.tsx
  56. 1 0
      packages/app/src/styles/_layout.scss
  57. 31 19
      packages/app/src/styles/_override-bootstrap-variables.scss
  58. 11 6
      packages/app/src/styles/_page-tree.scss
  59. 12 9
      packages/app/src/styles/_search.scss
  60. 2 7
      packages/app/src/styles/_sidebar.scss
  61. 0 2
      packages/app/src/styles/_subnav.scss
  62. 1 1
      packages/app/src/styles/_tag.scss
  63. 4 1
      packages/app/src/styles/atoms/_buttons.scss
  64. 8 2
      packages/app/src/styles/theme/_apply-colors-dark.scss
  65. 8 2
      packages/app/src/styles/theme/_apply-colors-light.scss
  66. 13 0
      packages/app/src/styles/theme/_apply-colors.scss
  67. 78 0
      packages/app/src/test/integration/service/page.test.js
  68. 4 2
      packages/ui/src/components/User/UserPicture.jsx
  69. 120 87
      yarn.lock

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

+ 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 />,
 
 
@@ -128,6 +124,10 @@ if (pageContainer.state.pageId != null) {
 
 
     'recent-created-icon': <RecentlyCreatedIcon />,
     'recent-created-icon': <RecentlyCreatedIcon />,
     'user-bookmark-icon': <BookmarkIcon />,
     'user-bookmark-icon': <BookmarkIcon />,
+    'not-found-alert': <NotFoundAlert
+      isGuestUserMode={appContainer.isGuestUser}
+      isHidden={pageContainer.state.isNotCreatable || pageContainer.state.isTrashPage}
+    />,
   });
   });
 
 
   // show the Page accessory modal when query of "compare" is requested
   // show the Page accessory modal when query of "compare" is requested

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

+ 0 - 1
packages/app/src/client/services/EditorContainer.js

@@ -27,7 +27,6 @@ export default class EditorContainer extends Container {
     this.state = {
     this.state = {
       tags: null,
       tags: null,
 
 
-      isSlackEnabled: false,
       slackChannels: mainContent.getAttribute('data-slack-channels') || '',
       slackChannels: mainContent.getAttribute('data-slack-channels') || '',
 
 
       grant: 1, // default: public
       grant: 1, // default: public

+ 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

+ 5 - 2
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 = () => {
@@ -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/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()}

+ 13 - 14
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,14 @@ 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';
 
 
 const EditorNavbarBottom = (props) => {
 const EditorNavbarBottom = (props) => {
 
 
@@ -28,9 +29,13 @@ 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 additionalClasses = ['grw-editor-navbar-bottom'];
   const additionalClasses = ['grw-editor-navbar-bottom'];
 
 
+  const isSlackEnabledToggleHandler = useCallback(
+    bool => mutateIsSlackEnabled(bool), [mutateIsSlackEnabled],
+  );
+
   const renderDrawerButton = () => (
   const renderDrawerButton = () => (
     <button
     <button
       type="button"
       type="button"
@@ -41,10 +46,6 @@ const EditorNavbarBottom = (props) => {
     </button>
     </button>
   );
   );
 
 
-  const slackEnabledFlagChangedHandler = (isSlackEnabled) => {
-    props.editorContainer.setState({ isSlackEnabled });
-  };
-
   const slackChannelsChangedHandler = (slackChannels) => {
   const slackChannelsChangedHandler = (slackChannels) => {
     props.editorContainer.setState({ slackChannels });
     props.editorContainer.setState({ slackChannels });
   };
   };
@@ -69,15 +70,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}
+              isSlackEnabled={isSlackEnabled ?? false}
               slackChannels={props.editorContainer.state.slackChannels}
               slackChannels={props.editorContainer.state.slackChannels}
-              onEnabledFlagChange={slackEnabledFlagChangedHandler}
+              onEnabledFlagChange={isSlackEnabledToggleHandler}
               onChannelChange={slackChannelsChangedHandler}
               onChannelChange={slackChannelsChangedHandler}
               id="idForEditorNavbarBottomForMobile"
               id="idForEditorNavbarBottomForMobile"
-              popUp
             />
             />
           </nav>
           </nav>
         </Collapse>
         </Collapse>
@@ -104,12 +104,11 @@ const EditorNavbarBottom = (props) => {
           ) : (
           ) : (
             <div className="mr-2">
             <div className="mr-2">
               <SlackNotification
               <SlackNotification
-                isSlackEnabled={props.editorContainer.state.isSlackEnabled}
+                isSlackEnabled={isSlackEnabled ?? false}
                 slackChannels={props.editorContainer.state.slackChannels}
                 slackChannels={props.editorContainer.state.slackChannels}
-                onEnabledFlagChange={slackEnabledFlagChangedHandler}
+                onEnabledFlagChange={isSlackEnabledToggleHandler}
                 onChannelChange={slackChannelsChangedHandler}
                 onChannelChange={slackChannelsChangedHandler}
                 id="idForEditorNavbarBottom"
                 id="idForEditorNavbarBottom"
-                popUp={false}
               />
               />
             </div>
             </div>
           ))}
           ))}

+ 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 align-self-center"
         disabled={props.isSelectAllCheckboxDisabled}
         disabled={props.isSelectAllCheckboxDisabled}
         onClick={onClickCheckbox}
         onClick={onClickCheckbox}
         checked={selectAllCheckboxType !== CheckboxType.NONE_CHECKED}
         checked={selectAllCheckboxType !== CheckboxType.NONE_CHECKED}

+ 28 - 24
packages/app/src/components/SearchPage/SearchControl.tsx

@@ -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

+ 7 - 2
packages/app/src/components/SearchPage/SearchPageLayout.tsx

@@ -49,9 +49,14 @@ 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>

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

+ 31 - 16
packages/app/src/components/SearchPage/SearchResultListItem.tsx

@@ -1,40 +1,48 @@
-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 { 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;
 
 
   // 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}
     />
     />
   );
   );
 
 
@@ -70,7 +78,7 @@ const SearchResultListItem: FC<Props> = (props:Props) => {
               {/* page title */}
               {/* page title */}
               <h3 className="mb-0">
               <h3 className="mb-0">
                 <UserPicture user={pageData.lastUpdateUser} />
                 <UserPicture user={pageData.lastUpdateUser} />
-                <span className="mx-2 search-result-page-title">{dPagePath.latter}</span>
+                <span className="mx-2 search-result-page-title">{pageTitle}</span>
               </h3>
               </h3>
               {/* page meta */}
               {/* page meta */}
               <div className="d-flex mx-2">
               <div className="d-flex mx-2">
@@ -78,17 +86,24 @@ const SearchResultListItem: FC<Props> = (props:Props) => {
               </div>
               </div>
               {/* doropdown icon includes page control buttons */}
               {/* doropdown icon includes page control buttons */}
               <div className="ml-auto">
               <div className="ml-auto">
-                <PageItemControl page={pageData} onClickDeleteButton={props.onClickDeleteButton} isEnableActions={isEnableActions} />
+                <PageItemControl
+                  page={pageData}
+                  onClickDeleteButton={props.onClickDeleteButton}
+                  isEnableActions={isEnableActions}
+                  isDeletable={!isTopPage(pageData.path)}
+                />
               </div>
               </div>
             </div>
             </div>
             <div className="my-2 search-result-list-snippet">
             <div className="my-2 search-result-list-snippet">
-              {
-                pageMeta.elasticSearchResult != null && (
-                  <Clamp lines={2}>
+              <Clamp lines={2}>
+                {
+                  pageMeta.elasticSearchResult != null && pageMeta.elasticSearchResult?.snippet.length !== 0 ? (
                     <div className="mt-1" dangerouslySetInnerHTML={{ __html: pageMeta.elasticSearchResult.snippet }}></div>
                     <div className="mt-1" dangerouslySetInnerHTML={{ __html: pageMeta.elasticSearchResult.snippet }}></div>
-                  </Clamp>
-                )
-              }
+                  ) : (
+                    <div className="mt-1">{ shortBody != null ? shortBody : 'Loading ...' }</div> // TODO: improve indicator
+                  )
+                }
+              </Clamp>
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>
@@ -96,6 +111,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

@@ -79,13 +79,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,
         };
         };
       }
       }
 
 

+ 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 %}

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

+ 12 - 9
packages/app/src/styles/_search.scss

@@ -73,19 +73,17 @@
   .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 +161,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;

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

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

+ 120 - 87
yarn.lock

@@ -1883,9 +1883,9 @@
   integrity sha512-oN3y7FAROHhrAt7Rr7PnTSwrHrZVRTS2ZbyxeQwSSYD0ifwM3YNgQqbaRmjcWoPyq77MjchusjJDspbzMmip1Q==
   integrity sha512-oN3y7FAROHhrAt7Rr7PnTSwrHrZVRTS2ZbyxeQwSSYD0ifwM3YNgQqbaRmjcWoPyq77MjchusjJDspbzMmip1Q==
 
 
 "@npmcli/fs@^1.0.0":
 "@npmcli/fs@^1.0.0":
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/@npmcli/fs/-/fs-1.0.0.tgz#589612cfad3a6ea0feafcb901d29c63fd52db09f"
-  integrity sha512-8ltnOpRR/oJbOp8vaGUnipOi3bqkcW+sLHFlyXIr08OGHmVJLB1Hn7QtGXbYcpVtH1gAYZTlmDXtE4YV0+AMMQ==
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@npmcli/fs/-/fs-1.1.0.tgz#bec1d1b89c170d40e1b73ad6c943b0b75e7d2951"
+  integrity sha512-VhP1qZLXcrXRIaPoqb4YA55JQxLNF3jNR4T55IdOJa3+IFJKNYHtPvtXx8slmeMavj37vCzCfrqQM1vWLsYKLA==
   dependencies:
   dependencies:
     "@gar/promisify" "^1.0.1"
     "@gar/promisify" "^1.0.1"
     semver "^7.3.5"
     semver "^7.3.5"
@@ -3701,7 +3701,7 @@ aproba@^1.0.3, aproba@^1.1.1:
   version "1.2.0"
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
   resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
 
 
-aproba@^2.0.0:
+"aproba@^1.0.3 || ^2.0.0", aproba@^2.0.0:
   version "2.0.0"
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc"
   resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc"
   integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==
   integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==
@@ -3735,6 +3735,14 @@ archiver@^5.3.0:
     tar-stream "^2.2.0"
     tar-stream "^2.2.0"
     zip-stream "^4.1.0"
     zip-stream "^4.1.0"
 
 
+are-we-there-yet@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c"
+  integrity sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==
+  dependencies:
+    delegates "^1.0.0"
+    readable-stream "^3.6.0"
+
 are-we-there-yet@~1.1.2:
 are-we-there-yet@~1.1.2:
   version "1.1.5"
   version "1.1.5"
   resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21"
   resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21"
@@ -4050,21 +4058,20 @@ autoprefixer@^9.0.0:
     postcss "^7.0.0"
     postcss "^7.0.0"
     postcss-value-parser "^3.2.3"
     postcss-value-parser "^3.2.3"
 
 
-aws-sdk@^2.2.36, aws-sdk@^2.88.0:
-  version "2.179.0"
-  resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.179.0.tgz#48e07843c6ae83d6752e58547b168299f140cc11"
+aws-sdk@^2.1044.0, aws-sdk@^2.2.36:
+  version "2.1044.0"
+  resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.1044.0.tgz#0708eaf48daf8d961b414e698d84e8cd1f82c4ad"
+  integrity sha512-n55uGUONQGXteGGG1QlZ1rKx447KSuV/x6jUGNf2nOl41qMI8ZgLUhNUt0uOtw3qJrCTanzCyR/JKBq2PMiqEQ==
   dependencies:
   dependencies:
-    buffer "4.9.1"
-    create-hash "^1.1.3"
-    create-hmac "^1.1.6"
-    events "^1.1.1"
+    buffer "4.9.2"
+    events "1.1.1"
+    ieee754 "1.1.13"
     jmespath "0.15.0"
     jmespath "0.15.0"
     querystring "0.2.0"
     querystring "0.2.0"
     sax "1.2.1"
     sax "1.2.1"
     url "0.10.3"
     url "0.10.3"
-    uuid "3.1.0"
-    xml2js "0.4.17"
-    xmlbuilder "4.2.1"
+    uuid "3.3.2"
+    xml2js "0.4.19"
 
 
 aws-sign2@~0.7.0:
 aws-sign2@~0.7.0:
   version "0.7.0"
   version "0.7.0"
@@ -4734,9 +4741,10 @@ buffer-xor@^1.0.3:
   version "1.0.3"
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9"
   resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9"
 
 
-buffer@4.9.1, buffer@^4.3.0:
-  version "4.9.1"
-  resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.1.tgz#6d1bb601b07a4efced97094132093027c95bc298"
+buffer@4.9.2, buffer@^4.3.0:
+  version "4.9.2"
+  resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.2.tgz#230ead344002988644841ab0244af8c44bbe3ef8"
+  integrity sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==
   dependencies:
   dependencies:
     base64-js "^1.0.2"
     base64-js "^1.0.2"
     ieee754 "^1.1.4"
     ieee754 "^1.1.4"
@@ -5627,6 +5635,11 @@ color-string@^1.5.2:
     color-name "^1.0.0"
     color-name "^1.0.0"
     simple-swizzle "^0.2.2"
     simple-swizzle "^0.2.2"
 
 
+color-support@^1.1.2:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2"
+  integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==
+
 color@^3.0.0:
 color@^3.0.0:
   version "3.0.0"
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/color/-/color-3.0.0.tgz#d920b4328d534a3ac8295d68f7bd4ba6c427be9a"
   resolved "https://registry.yarnpkg.com/color/-/color-3.0.0.tgz#d920b4328d534a3ac8295d68f7bd4ba6c427be9a"
@@ -5907,7 +5920,7 @@ console-browserify@1.1.x, console-browserify@^1.1.0:
   dependencies:
   dependencies:
     date-now "^0.1.4"
     date-now "^0.1.4"
 
 
-console-control-strings@^1.0.0, console-control-strings@~1.1.0:
+console-control-strings@^1.0.0, console-control-strings@^1.1.0, console-control-strings@~1.1.0:
   version "1.1.0"
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
   resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
   integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=
   integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=
@@ -6240,7 +6253,7 @@ create-error-class@^3.0.0:
   dependencies:
   dependencies:
     capture-stack-trace "^1.0.0"
     capture-stack-trace "^1.0.0"
 
 
-create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.1.3:
+create-hash@^1.1.0, create-hash@^1.1.2:
   version "1.1.3"
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.1.3.tgz#606042ac8b9262750f483caddab0f5819172d8fd"
   resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.1.3.tgz#606042ac8b9262750f483caddab0f5819172d8fd"
   dependencies:
   dependencies:
@@ -6249,7 +6262,7 @@ create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.1.3:
     ripemd160 "^2.0.0"
     ripemd160 "^2.0.0"
     sha.js "^2.4.0"
     sha.js "^2.4.0"
 
 
-create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4, create-hmac@^1.1.6:
+create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4:
   version "1.1.6"
   version "1.1.6"
   resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.6.tgz#acb9e221a4e17bdb076e90657c42b93e3726cf06"
   resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.6.tgz#acb9e221a4e17bdb076e90657c42b93e3726cf06"
   dependencies:
   dependencies:
@@ -8009,7 +8022,7 @@ eventemitter3@^4.0.4:
   resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
   resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
   integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
   integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
 
 
-events@^1.1.1:
+events@1.1.1:
   version "1.1.1"
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924"
   resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924"
 
 
@@ -8938,6 +8951,21 @@ functional-red-black-tree@^1.0.1:
   version "1.0.1"
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
   resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
 
 
+gauge@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/gauge/-/gauge-4.0.0.tgz#afba07aa0374a93c6219603b1fb83eaa2264d8f8"
+  integrity sha512-F8sU45yQpjQjxKkm1UOAhf0U/O0aFt//Fl7hsrNVto+patMHjs7dPI9mFOGUKbhrgKm0S3EjW3scMFuQmWSROw==
+  dependencies:
+    ansi-regex "^5.0.1"
+    aproba "^1.0.3 || ^2.0.0"
+    color-support "^1.1.2"
+    console-control-strings "^1.0.0"
+    has-unicode "^2.0.1"
+    signal-exit "^3.0.0"
+    string-width "^4.2.3"
+    strip-ansi "^6.0.1"
+    wide-align "^1.1.2"
+
 gauge@~2.7.3:
 gauge@~2.7.3:
   version "2.7.4"
   version "2.7.4"
   resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"
   resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"
@@ -10024,15 +10052,16 @@ icss-utils@^4.0.0, icss-utils@^4.1.1:
   dependencies:
   dependencies:
     postcss "^7.0.14"
     postcss "^7.0.14"
 
 
-ieee754@^1.1.13, ieee754@^1.2.1:
+ieee754@1.1.13:
+  version "1.1.13"
+  resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
+  integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==
+
+ieee754@^1.1.13, ieee754@^1.1.4, ieee754@^1.2.1:
   version "1.2.1"
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
   resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
   integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
   integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
 
 
-ieee754@^1.1.4:
-  version "1.1.8"
-  resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.8.tgz#be33d40ac10ef1926701f6f08a2d86fbfd1ad3e4"
-
 iferr@^0.1.5:
 iferr@^0.1.5:
   version "0.1.5"
   version "0.1.5"
   resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501"
   resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501"
@@ -10702,7 +10731,7 @@ is-plain-obj@^2.0.0, is-plain-obj@^2.1.0:
   resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
   resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
   integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==
   integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==
 
 
-is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4:
+is-plain-object@^2.0.3, is-plain-object@^2.0.4:
   version "2.0.4"
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
   resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
   dependencies:
   dependencies:
@@ -12283,7 +12312,7 @@ lodash.uniqwith@^4.5.0:
   resolved "https://registry.yarnpkg.com/lodash.uniqwith/-/lodash.uniqwith-4.5.0.tgz#7a0cbf65f43b5928625a9d4d0dc54b18cadc7ef3"
   resolved "https://registry.yarnpkg.com/lodash.uniqwith/-/lodash.uniqwith-4.5.0.tgz#7a0cbf65f43b5928625a9d4d0dc54b18cadc7ef3"
   integrity sha1-egy/ZfQ7WShiWp1NDcVLGMrcfvM=
   integrity sha1-egy/ZfQ7WShiWp1NDcVLGMrcfvM=
 
 
-lodash@4.x, lodash@>=4.17.15, lodash@^4.0.0, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.2, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.7.0, lodash@~4.17.21:
+lodash@4.x, lodash@>=4.17.15, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.2, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.7.0, lodash@~4.17.21:
   version "4.17.21"
   version "4.17.21"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
   integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
   integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@@ -13663,7 +13692,7 @@ nan@^2.12.1, nan@^2.14.0:
   resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01"
   resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01"
   integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw==
   integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw==
 
 
-nan@^2.14.2:
+nan@^2.14.2, nan@^2.15.0:
   version "2.15.0"
   version "2.15.0"
   resolved "https://registry.yarnpkg.com/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee"
   resolved "https://registry.yarnpkg.com/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee"
   integrity sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==
   integrity sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==
@@ -13860,17 +13889,17 @@ node-gyp@^7.1.0:
     tar "^6.0.2"
     tar "^6.0.2"
     which "^2.0.2"
     which "^2.0.2"
 
 
-node-gyp@^8.0.0:
-  version "8.4.0"
-  resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-8.4.0.tgz#6e1112b10617f0f8559c64b3f737e8109e5a8338"
-  integrity sha512-Bi/oCm5bH6F+FmzfUxJpPaxMEyIhszULGR3TprmTeku8/dMFcdTcypk120NeZqEt54r1BrgEKtm2jJiuIKE28Q==
+node-gyp@^8.4.1:
+  version "8.4.1"
+  resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-8.4.1.tgz#3d49308fc31f768180957d6b5746845fbd429937"
+  integrity sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==
   dependencies:
   dependencies:
     env-paths "^2.2.0"
     env-paths "^2.2.0"
     glob "^7.1.4"
     glob "^7.1.4"
     graceful-fs "^4.2.6"
     graceful-fs "^4.2.6"
     make-fetch-happen "^9.1.0"
     make-fetch-happen "^9.1.0"
     nopt "^5.0.0"
     nopt "^5.0.0"
-    npmlog "^4.1.2"
+    npmlog "^6.0.0"
     rimraf "^3.0.2"
     rimraf "^3.0.2"
     semver "^7.3.5"
     semver "^7.3.5"
     tar "^6.1.2"
     tar "^6.1.2"
@@ -14203,6 +14232,16 @@ npmlog@^4.0.2, npmlog@^4.1.2:
     gauge "~2.7.3"
     gauge "~2.7.3"
     set-blocking "~2.0.0"
     set-blocking "~2.0.0"
 
 
+npmlog@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-6.0.0.tgz#ba9ef39413c3d936ea91553db7be49c34ad0520c"
+  integrity sha512-03ppFRGlsyUaQFbGC2C8QWJN/C/K7PsfyD9aQdhVKAQIH4sQBc8WASqFBP7O+Ut4d2oo5LoeoboB3cGdBZSp6Q==
+  dependencies:
+    are-we-there-yet "^2.0.0"
+    console-control-strings "^1.1.0"
+    gauge "^4.0.0"
+    set-blocking "^2.0.0"
+
 nth-check@^1.0.1:
 nth-check@^1.0.1:
   version "1.0.1"
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.1.tgz#9929acdf628fc2c41098deab82ac580cf149aae4"
   resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.1.tgz#9929acdf628fc2c41098deab82ac580cf149aae4"
@@ -16354,14 +16393,14 @@ rc@>=1.2.8, rc@^1.0.1, rc@^1.1.6, rc@^1.2.7:
     minimist "^1.2.0"
     minimist "^1.2.0"
     strip-json-comments "~2.0.1"
     strip-json-comments "~2.0.1"
 
 
-re2@^1.16.0:
-  version "1.16.0"
-  resolved "https://registry.yarnpkg.com/re2/-/re2-1.16.0.tgz#f311eb4865b1296123800ea8e013cec8dab25590"
-  integrity sha512-eizTZL2ZO0ZseLqfD4t3Qd0M3b3Nr0MBWpX81EbPMIud/1d/CSfUIx2GQK8fWiAeHoSekO5EOeFib2udTZLwYw==
+re2@^1.17.1:
+  version "1.17.1"
+  resolved "https://registry.yarnpkg.com/re2/-/re2-1.17.1.tgz#0202025aa20dd574a8cdb439811761d88b70ae59"
+  integrity sha512-TrhxVzakyO/WJsErkc01zjlEiDLCuuRuddbVi2I8YasIbh6MEJfkRoajBRj+ggm00gnGI2EMemE9GrlKrgUZ8Q==
   dependencies:
   dependencies:
     install-artifact-from-github "^1.2.0"
     install-artifact-from-github "^1.2.0"
-    nan "^2.14.2"
-    node-gyp "^8.0.0"
+    nan "^2.15.0"
+    node-gyp "^8.4.1"
 
 
 react-bootstrap-typeahead@^3.4.7:
 react-bootstrap-typeahead@^3.4.7:
   version "3.4.7"
   version "3.4.7"
@@ -17761,18 +17800,9 @@ set-immediate-shim@^1.0.1:
   version "1.0.1"
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61"
   resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61"
 
 
-set-value@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/set-value/-/set-value-0.4.3.tgz#7db08f9d3d22dc7f78e53af3c3bf4666ecdfccf1"
-  dependencies:
-    extend-shallow "^2.0.1"
-    is-extendable "^0.1.1"
-    is-plain-object "^2.0.1"
-    to-object-path "^0.3.0"
-
-set-value@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.0.tgz#71ae4a88f0feefbbf52d1ea604f3fb315ebb6274"
+set-value@^2.0.0, set-value@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b"
   dependencies:
   dependencies:
     extend-shallow "^2.0.1"
     extend-shallow "^2.0.1"
     is-extendable "^0.1.1"
     is-extendable "^0.1.1"
@@ -18265,9 +18295,9 @@ socks-proxy-agent@^5.0.0:
     socks "^2.3.3"
     socks "^2.3.3"
 
 
 socks-proxy-agent@^6.0.0:
 socks-proxy-agent@^6.0.0:
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-6.1.0.tgz#869cf2d7bd10fea96c7ad3111e81726855e285c3"
-  integrity sha512-57e7lwCN4Tzt3mXz25VxOErJKXlPfXmkMLnk310v/jwW20jWRVcgsOit+xNkN3eIEdB47GwnfAEBLacZ/wVIKg==
+  version "6.1.1"
+  resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-6.1.1.tgz#e664e8f1aaf4e1fb3df945f09e3d94f911137f87"
+  integrity sha512-t8J0kG3csjA4g6FTbsMOWws+7R7vuRC8aQ/wy3/1OWmsgwA68zs/+cExQ0koSitUDXqhufF/YJr9wtNMZHw5Ew==
   dependencies:
   dependencies:
     agent-base "^6.0.2"
     agent-base "^6.0.2"
     debug "^4.3.1"
     debug "^4.3.1"
@@ -18678,6 +18708,15 @@ string-width@^1.0.1, string-width@^1.0.2:
     is-fullwidth-code-point "^2.0.0"
     is-fullwidth-code-point "^2.0.0"
     strip-ansi "^4.0.0"
     strip-ansi "^4.0.0"
 
 
+"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
+  version "4.2.3"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
+  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+  dependencies:
+    emoji-regex "^8.0.0"
+    is-fullwidth-code-point "^3.0.0"
+    strip-ansi "^6.0.1"
+
 string-width@^3.0.0, string-width@^3.1.0:
 string-width@^3.0.0, string-width@^3.1.0:
   version "3.1.0"
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961"
@@ -18687,15 +18726,6 @@ string-width@^3.0.0, string-width@^3.1.0:
     is-fullwidth-code-point "^2.0.0"
     is-fullwidth-code-point "^2.0.0"
     strip-ansi "^5.1.0"
     strip-ansi "^5.1.0"
 
 
-string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
-  version "4.2.3"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
-  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
-  dependencies:
-    emoji-regex "^8.0.0"
-    is-fullwidth-code-point "^3.0.0"
-    strip-ansi "^6.0.1"
-
 string.prototype.matchall@^4.0.5:
 string.prototype.matchall@^4.0.5:
   version "4.0.5"
   version "4.0.5"
   resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.5.tgz#59370644e1db7e4c0c045277690cf7b01203c4da"
   resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.5.tgz#59370644e1db7e4c0c045277690cf7b01203c4da"
@@ -20424,13 +20454,13 @@ unified@^9.2.1:
     vfile "^4.0.0"
     vfile "^4.0.0"
 
 
 union-value@^1.0.0:
 union-value@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.0.tgz#5c71c34cb5bad5dcebe3ea0cd08207ba5aa1aea4"
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847"
   dependencies:
   dependencies:
     arr-union "^3.1.0"
     arr-union "^3.1.0"
     get-value "^2.0.6"
     get-value "^2.0.6"
     is-extendable "^0.1.1"
     is-extendable "^0.1.1"
-    set-value "^0.4.3"
+    set-value "^2.0.1"
 
 
 uniq@^1.0.1:
 uniq@^1.0.1:
   version "1.0.1"
   version "1.0.1"
@@ -20784,21 +20814,17 @@ utils-merge@1.0.1, utils-merge@1.x.x:
   version "1.0.1"
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
   resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
 
 
-uuid@3.1.0, uuid@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04"
+uuid@3.3.2:
+  version "3.3.2"
+  resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"
+  integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==
 
 
-uuid@8.3.2, uuid@^8.0.0:
+uuid@8.3.2, uuid@>=8.1.0, uuid@^8.0.0:
   version "8.3.2"
   version "8.3.2"
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
   integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
   integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
 
 
-uuid@>=8.1.0:
-  version "8.1.0"
-  resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.1.0.tgz#6f1536eb43249f473abc6bd58ff983da1ca30d8d"
-  integrity sha512-CI18flHDznR0lq54xBycOVmphdCYnQLKn8abKn7PXUiKUGdEd+/l9LWNJmugXel4hXq7S+RMNl34ecyC9TntWg==
-
-uuid@^3.0.1, uuid@^3.3.2:
+uuid@^3.0.1, uuid@^3.1.0, uuid@^3.3.2:
   version "3.4.0"
   version "3.4.0"
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
   integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
   integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
@@ -21201,6 +21227,13 @@ wide-align@^1.1.0:
   dependencies:
   dependencies:
     string-width "^1.0.2 || 2"
     string-width "^1.0.2 || 2"
 
 
+wide-align@^1.1.2:
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3"
+  integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==
+  dependencies:
+    string-width "^1.0.2 || 2 || 3 || 4"
+
 widest-line@^2.0.0:
 widest-line@^2.0.0:
   version "2.0.1"
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-2.0.1.tgz#7438764730ec7ef4381ce4df82fb98a53142a3fc"
   resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-2.0.1.tgz#7438764730ec7ef4381ce4df82fb98a53142a3fc"
@@ -21431,12 +21464,13 @@ xml-name-validator@^3.0.0:
   resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
   resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
   integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==
   integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==
 
 
-xml2js@0.4.17:
-  version "0.4.17"
-  resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.17.tgz#17be93eaae3f3b779359c795b419705a8817e868"
+xml2js@0.4.19:
+  version "0.4.19"
+  resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.19.tgz#686c20f213209e94abf0d1bcf1efaa291c7827a7"
+  integrity sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==
   dependencies:
   dependencies:
     sax ">=0.6.0"
     sax ">=0.6.0"
-    xmlbuilder "^4.1.0"
+    xmlbuilder "~9.0.1"
 
 
 xml2js@^0.4.23:
 xml2js@^0.4.23:
   version "0.4.23"
   version "0.4.23"
@@ -21446,12 +21480,6 @@ xml2js@^0.4.23:
     sax ">=0.6.0"
     sax ">=0.6.0"
     xmlbuilder "~11.0.0"
     xmlbuilder "~11.0.0"
 
 
-xmlbuilder@4.2.1, xmlbuilder@^4.1.0:
-  version "4.2.1"
-  resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-4.2.1.tgz#aa58a3041a066f90eaa16c2f5389ff19f3f461a5"
-  dependencies:
-    lodash "^4.0.0"
-
 xmlbuilder@^15.1.1:
 xmlbuilder@^15.1.1:
   version "15.1.1"
   version "15.1.1"
   resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz#9dcdce49eea66d8d10b42cae94a79c3c8d0c2ec5"
   resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz#9dcdce49eea66d8d10b42cae94a79c3c8d0c2ec5"
@@ -21462,6 +21490,11 @@ xmlbuilder@~11.0.0:
   resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3"
   resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3"
   integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==
   integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==
 
 
+xmlbuilder@~9.0.1:
+  version "9.0.7"
+  resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d"
+  integrity sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=
+
 xmlchars@^2.2.0:
 xmlchars@^2.2.0:
   version "2.2.0"
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"
   resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"