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

Merge commit '7d13b05c8611af2735584ce7c394fa231b41948d' into imprv/82973-replace-createdAt-to-swr

kaori 4 лет назад
Родитель
Сommit
ffe3f419ca
100 измененных файлов с 2208 добавлено и 960 удалено
  1. 28 1
      CHANGELOG.md
  2. 1 1
      lerna.json
  3. 1 1
      package.json
  4. 1 1
      packages/app/bin/download-cdn-resources.ts
  5. 0 2
      packages/app/config/webpack.common.js
  6. 0 1
      packages/app/config/webpack.dev.dll.js
  7. 2 2
      packages/app/docker/README.md
  8. 15 15
      packages/app/package.json
  9. 10 0
      packages/app/resource/locales/en_US/notifications/userActivation.txt
  10. 11 3
      packages/app/resource/locales/en_US/translation.json
  11. 11 0
      packages/app/resource/locales/ja_JP/notifications/userActivation.txt
  12. 10 2
      packages/app/resource/locales/ja_JP/translation.json
  13. 10 0
      packages/app/resource/locales/zh_CN/notifications/userActivation.txt
  14. 11 3
      packages/app/resource/locales/zh_CN/translation.json
  15. 1 1
      packages/app/src/client/app.jsx
  16. 25 0
      packages/app/src/client/nologin.jsx
  17. 12 1
      packages/app/src/client/services/AdminLocalSecurityContainer.js
  18. 20 2
      packages/app/src/client/services/ContextExtractor.tsx
  19. 3 5
      packages/app/src/client/services/EditorContainer.js
  20. 47 0
      packages/app/src/client/util/codemirror/drawio-fold.ext.js
  21. 15 0
      packages/app/src/client/util/editor.ts
  22. 1 1
      packages/app/src/client/util/interceptor/detach-code-blocks.js
  23. 17 2
      packages/app/src/client/util/interceptor/drawio-interceptor.js
  24. 1 1
      packages/app/src/components/Admin/App/AppSettingsPageContents.jsx
  25. 46 2
      packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  26. 148 0
      packages/app/src/components/CompleteUserRegistrationForm.tsx
  27. 59 27
      packages/app/src/components/LoginForm.jsx
  28. 26 7
      packages/app/src/components/Page.jsx
  29. 1 1
      packages/app/src/components/PageComment/CommentEditor.jsx
  30. 23 5
      packages/app/src/components/PageEditor.jsx
  31. 32 3
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  32. 21 21
      packages/app/src/components/PageEditor/EditorNavbarBottom.tsx
  33. 16 0
      packages/app/src/components/PageEditor/MarkdownDrawioUtil.js
  34. 1 1
      packages/app/src/components/PageEditor/MarkdownTableInterceptor.js
  35. 36 0
      packages/app/src/components/PageEditor/OptionsSelector.jsx
  36. 1 1
      packages/app/src/components/PageEditor/PreventMarkdownListInterceptor.js
  37. 22 5
      packages/app/src/components/PageEditorByHackmd.jsx
  38. 26 6
      packages/app/src/components/SavePageControls.jsx
  39. 3 8
      packages/app/src/components/SearchTypeahead.jsx
  40. 13 72
      packages/app/src/components/Sidebar.tsx
  41. 2 1
      packages/app/src/components/Sidebar/CustomSidebar.tsx
  42. 0 87
      packages/app/src/components/SlackNotification.jsx
  43. 67 0
      packages/app/src/components/SlackNotification.tsx
  44. 8 3
      packages/app/src/components/StickyStretchableScroller.jsx
  45. 7 0
      packages/app/src/interfaces/common.ts
  46. 14 0
      packages/app/src/interfaces/named-query.ts
  47. 26 4
      packages/app/src/interfaces/page.ts
  48. 31 0
      packages/app/src/interfaces/search.ts
  49. 1 1
      packages/app/src/server/crowi/index.js
  50. 45 0
      packages/app/src/server/interfaces/search.ts
  51. 22 0
      packages/app/src/server/middlewares/inject-user-registration-order-by-token-middleware.ts
  52. 35 0
      packages/app/src/server/middlewares/inject-user-ui-settings-to-localvars.ts
  53. 1 1
      packages/app/src/server/models/GlobalNotificationSetting/index.js
  54. 36 0
      packages/app/src/server/models/named-query.ts
  55. 1 1
      packages/app/src/server/models/page.js
  56. 67 0
      packages/app/src/server/models/user-registration-order.ts
  57. 2 2
      packages/app/src/server/models/user-ui-settings.ts
  58. 10 0
      packages/app/src/server/models/user.js
  59. 1 1
      packages/app/src/server/routes/apiv3/app-settings.js
  60. 12 0
      packages/app/src/server/routes/apiv3/index.js
  61. 1 1
      packages/app/src/server/routes/apiv3/pages.js
  62. 3 0
      packages/app/src/server/routes/apiv3/security-setting.js
  63. 138 0
      packages/app/src/server/routes/apiv3/user-activation.ts
  64. 0 18
      packages/app/src/server/routes/apiv3/user-ui-settings.ts
  65. 25 17
      packages/app/src/server/routes/index.js
  66. 1 1
      packages/app/src/server/routes/page.js
  67. 20 37
      packages/app/src/server/routes/search.js
  68. 114 0
      packages/app/src/server/routes/user-activation.ts
  69. 9 6
      packages/app/src/server/routes/user.js
  70. 7 1
      packages/app/src/server/service/config-loader.ts
  71. 119 181
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  72. 0 49
      packages/app/src/server/service/search-delegator/searchbox.js
  73. 0 158
      packages/app/src/server/service/search.js
  74. 421 0
      packages/app/src/server/service/search.ts
  75. 1 1
      packages/app/src/server/service/slack-command-handler/create-page-service.js
  76. 1 1
      packages/app/src/server/service/slack-command-handler/search.js
  77. 1 1
      packages/app/src/server/util/middlewares.js
  78. 1 1
      packages/app/src/server/util/swigFunctions.js
  79. 6 0
      packages/app/src/server/views/layout/layout.html
  80. 3 2
      packages/app/src/server/views/login.html
  81. 52 0
      packages/app/src/server/views/user-activation.html
  82. 1 1
      packages/app/src/services/cdn-resources-service.js
  83. 3 0
      packages/app/src/stores/context.tsx
  84. 9 0
      packages/app/src/stores/editor.tsx
  85. 15 69
      packages/app/src/stores/ui.tsx
  86. 1 0
      packages/app/src/styles/_layout.scss
  87. 3 17
      packages/app/src/styles/_sidebar.scss
  88. 4 1
      packages/app/src/styles/atoms/_buttons.scss
  89. 0 36
      packages/app/src/test/integration/service/search-delegator/searchbox.test.js
  90. 114 0
      packages/app/src/test/integration/service/search/search-service.test.js
  91. 1 1
      packages/codemirror-textlint/package.json
  92. 0 27
      packages/core/README.md
  93. 1 1
      packages/core/package.json
  94. 11 14
      packages/core/src/index.js
  95. 2 4
      packages/core/src/plugin/service/tag-cache-manager.js
  96. 1 3
      packages/core/src/service/localstorage-manager.js
  97. 1 3
      packages/core/src/utils/basic-interceptor.js
  98. 1 1
      packages/plugin-attachment-refs/package.json
  99. 1 1
      packages/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPostRenderInterceptor.js
  100. 1 1
      packages/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPreRenderInterceptor.js

+ 28 - 1
CHANGELOG.md

@@ -1,9 +1,36 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.5.2...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v4.5.3...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v4.5.3](https://github.com/weseek/growi/compare/v4.5.2...v4.5.3) - 2021-12-17
+
+### 💎 Features
+
+- feat: user activation by email (#4862) @kaoritokashiki
+
+### 🚀 Improvement
+
+- imprv: Use SWR for isSlackEnabled (#4827) @stevenfukase
+- imprv: Disable rubber band scroll for Mac & iOS users (#4834) @hakumizuki
+- imprv: Omit atlaskit and implement sidebar only with original codes (#4598) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- fix: GROWI Bot search command after transplanting search service from dev/5.0.x (#4916) @hakumizuki
+- fix: Set min-height to sidebar scroll target (#4884) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: fix dependabot alert for kind-of (#4891) @LuqmanHakim-Grune
+- support: fix dependabot alert for ini (#4892) @LuqmanHakim-Grune
+- support: fix and debug mixin-deep dependabot alert (#4867) @LuqmanHakim-Grune
+- support: dependabot alert xmlhttprequest-ssl (#4878) @mudana-grune
+- support: Transplant search service from dev/5.0.x (#4869) @hakumizuki
+- support: dependabot alert set-value (#4864) @LuqmanHakim-Grune
+- ci(deps): bump aws-sdk from 2.179.0 to 2.1044.0 (#4821) @dependabot
+
 ## [v4.5.2](https://github.com/weseek/growi/compare/v4.5.1...v4.5.2) - 2021-12-06
 
 ### 🐛 Bug Fixes

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
   "npmClient": "yarn",
   "useWorkspaces": true,
-  "version": "4.5.3-RC.0",
+  "version": "4.5.4-RC.0",
   "packages": [
     "packages/*"
   ]

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.5.3-RC.0",
+  "version": "4.5.4-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 1 - 1
packages/app/bin/download-cdn-resources.ts

@@ -3,7 +3,7 @@
  *
  * @author Yuki Takei <yuki@weseek.co.jp>
  */
-import { envUtils } from 'growi-commons';
+import { envUtils } from '@growi/core';
 
 import CdnResourcesDownloader from './cdn/cdn-resources-downloader';
 import loggerFactory from '../src/utils/logger';

+ 0 - 2
packages/app/config/webpack.common.js

@@ -83,8 +83,6 @@ module.exports = (options) => {
           exclude: {
             test: /node_modules/,
             exclude: [ // include as a result
-              { test: /node_modules\/growi-plugin-/ },
-              /node_modules\/growi-commons/,
               /node_modules\/codemirror/,
             ],
           },

+ 0 - 1
packages/app/config/webpack.dev.dll.js

@@ -17,7 +17,6 @@ module.exports = {
       'diff2html',
       'debug',
       'entities',
-      'growi-commons',
       'i18next', 'i18next-browser-languagedetector',
       'jquery-slimscroll',
       'lodash', 'pako',

+ 2 - 2
packages/app/docker/README.md

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`4.5.2`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.2/docker/Dockerfile)
-* [`4.5.2-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.2/docker/Dockerfile)
+* [`4.5.3`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.3/docker/Dockerfile)
+* [`4.5.3-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.3/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 

+ 15 - 15
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "4.5.3-RC.0",
+  "version": "4.5.4-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -58,11 +58,11 @@
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^4.5.3-RC.0",
-    "@growi/plugin-attachment-refs": "^4.5.3-RC.0",
-    "@growi/plugin-lsx": "^4.5.3-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^4.5.3-RC.0",
-    "@growi/slack": "^4.5.3-RC.0",
+    "@growi/codemirror-textlint": "^4.5.4-RC.0",
+    "@growi/plugin-attachment-refs": "^4.5.4-RC.0",
+    "@growi/plugin-lsx": "^4.5.4-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^4.5.4-RC.0",
+    "@growi/slack": "^4.5.4-RC.0",
     "@promster/express": "^5.1.0",
     "@promster/server": "^6.0.3",
     "@slack/events-api": "^3.0.0",
@@ -72,7 +72,7 @@
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
     "async-canvas-to-blob": "^1.0.3",
-    "aws-sdk": "^2.88.0",
+    "aws-sdk": "^2.1044.0",
     "axios": "^0.24.0",
     "body-parser": "^1.18.2",
     "browser-bunyan": "^1.6.3",
@@ -99,12 +99,11 @@
     "express-validator": "^6.1.1",
     "express-webpack-assets": "^0.1.0",
     "graceful-fs": "^4.1.11",
-    "growi-commons": "^5.0.4",
     "helmet": "^4.6.0",
     "http-errors": "~1.8.0",
     "i18next": "^20.3.2",
     "i18next-express-middleware": "^2.0.0",
-    "i18next-node-fs-backend": "^2.1.0",
+    "i18next-node-fs-backend": "^2.1.3",
     "i18next-sprintf-postprocessor": "^0.2.2",
     "is-iso-date": "^0.0.1",
     "lucene-query-parser": "^1.2.0",
@@ -131,6 +130,7 @@
     "passport-saml": "^3.2.0",
     "passport-twitter": "^1.0.4",
     "prom-client": "^13.0.0",
+    "re2": "^1.17.1",
     "react-card-flip": "^1.0.10",
     "react-image-crop": "^8.3.0",
     "reconnecting-websocket": "^4.4.0",
@@ -146,7 +146,7 @@
     "unzipper": "^0.10.5",
     "url-join": "^4.0.0",
     "validator": "^13.6.0",
-    "ws": "^7.4.6",
+    "ws": "^8.3.0",
     "xss": "^1.0.6"
   },
   "// comments for defDependencies": {
@@ -155,8 +155,8 @@
     "ts-loader": "v9 is not compatible with webpack@5"
   },
   "devDependencies": {
-    "@alienfast/i18next-loader": "^1.0.16",
-    "@growi/ui": "^4.5.3-RC.0",
+    "@alienfast/i18next-loader": "^1.1.4",
+    "@growi/ui": "^4.5.4-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
@@ -165,7 +165,7 @@
     "@types/react-dom": "^17.0.9",
     "autoprefixer": "^9.0.0",
     "bootstrap": "^4.5.0",
-    "browser-sync": "^2.26.3",
+    "browser-sync": "^2.27.7",
     "bunyan-debug": "^2.0.0",
     "cli": "~1.0.1",
     "codemirror": "^5.63.0",
@@ -175,7 +175,7 @@
     "css-loader": "^3.0.0",
     "csv-to-markdown-table": "^1.0.1",
     "diff2html": "^3.1.2",
-    "eazy-logger": "^3.0.2",
+    "eazy-logger": "^3.1.0",
     "file-loader": "^5.0.2",
     "handsontable": "=6.2.2",
     "hard-source-webpack-plugin": "^0.13.1",
@@ -243,7 +243,7 @@
     "unstated": "^2.1.1",
     "webpack": "^4.39.3",
     "webpack-assets-manifest": "^3.1.1",
-    "webpack-bundle-analyzer": "^3.0.2",
+    "webpack-bundle-analyzer": "^3.9.0",
     "webpack-cli": "^3.3.7",
     "webpack-merge": "^4.2.2"
   }

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

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

@@ -185,6 +185,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"
   },
   "page_register": {
+    "send_email": "Send email",
     "notice": {
       "restricted": "Admin approval required.",
       "restricted_defail": "Once the admin approves your sign up, you'll be able to access this wiki."
@@ -650,7 +651,12 @@
       "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": "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": {
       "enable_ldap": "Enable LDAP",
@@ -868,7 +874,7 @@
     "aws_sttings_required": "AWS settings required to use this function. Please ask the administrator.",
     "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)",
-    "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.",
     "can_not_register_maximum_number_of_users":"Can not register more than the maximum number of users.",
     "failed_to_register":"Failed to register.",
@@ -878,7 +884,9 @@
     "unable_to_use_this_user":"Unable to use this user.",
     "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.",
-    "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":{
     "create_bootstrap_4_grid":"Create Bootstrap 4 Grid",

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

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

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

@@ -187,6 +187,7 @@
     "v346_using_basic_auth": "現在利用中の Basic 認証機能は、近い将来<strong>廃止されます</strong>。%s から設定を削除してください。"
   },
   "page_register": {
+    "send_email": "メールを送る",
     "notice": {
       "restricted": "この Wiki への新規登録は制限されています。",
       "restricted_defail": "利用を開始するには、新規登録後、管理者による承認が必要です。"
@@ -647,7 +648,12 @@
       "enable_local": "ID/Password を有効にする",
       "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": {
       "enable_ldap": "LDAP を有効にする",
@@ -871,7 +877,9 @@
     "unable_to_use_this_user":"利用できないユーザーIDです。",
     "complete_to_install1":"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":{
     "create_bootstrap_4_grid":"Bootstrap 4 グリッドを作成",

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

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

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

@@ -185,6 +185,7 @@
 		"v346_using_basic_auth": "当前使用的基本身份验证在不久的将来将不再可用。从%s中删除设置"
 	},
 	"page_register": {
+    "send_email": "发电子邮件",
 		"notice": {
 			"restricted": "需要管理员批准。",
 			"restricted_defail": "一旦管理员批准您的注册,您就可以访问此wiki。"
@@ -636,7 +637,12 @@
       "enable_local": "Enable ID/Password",
       "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": {
 			"enable_ldap": "Enable LDAP",
@@ -871,7 +877,7 @@
 		"aws_sttings_required": "使用此功能所需的AWS设置。请询问管理员。",
 		"application_already_installed": "应用程序已安装。",
 		"email_address_could_not_be_used": "无法使用此电子邮件地址。(确保允许的电子邮件地址)",
-		"user_id_is_not_available.": "此用户ID不可用。",
+		"user_id_is_not_available": "此用户ID不可用。",
 		"email_address_is_already_registered": "此电子邮件地址已注册。",
 		"can_not_register_maximum_number_of_users": "注册的用户数不能超过最大值。",
 		"failed_to_register": "注册失败。",
@@ -881,7 +887,9 @@
 		"unable_to_use_this_user": "无法使用此用户。",
 		"complete_to_install1": "完成安装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":{
     "create_bootstrap_4_grid":"创建Bootstrap 4网格",

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

@@ -98,7 +98,7 @@ Object.assign(componentMappings, {
   'not-found-page': <NotFoundPage />,
   'not-found-alert': <NotFoundAlert
     isGuestUserMode={appContainer.isGuestUser}
-    isHidden={pageContainer.state.isNotCreatable || pageContainer.state.isTrashPage}
+    isHidden={pageContainer.state.pageId != null ? (pageContainer.state.isNotCreatable ?? pageContainer.state.isTrashPage) : false} // !!DO NOT MOVE THIS!! https://github.com/weseek/growi/pull/4899
   />,
 
   'forbidden-page': <ForbiddenPage />,

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

@@ -11,6 +11,7 @@ import InstallerForm from '../components/InstallerForm';
 import LoginForm from '../components/LoginForm';
 import PasswordResetRequestForm from '../components/PasswordResetRequestForm';
 import PasswordResetExecutionForm from '../components/PasswordResetExecutionForm';
+import CompleteUserRegistrationForm from '~/components/CompleteUserRegistrationForm';
 
 const i18n = i18nFactory();
 
@@ -39,6 +40,7 @@ if (loginFormElem) {
   const name = loginFormElem.dataset.name;
   const email = loginFormElem.dataset.email;
   const isRegistrationEnabled = loginFormElem.dataset.isRegistrationEnabled === 'true';
+  const isEmailAuthenticationEnabled = loginFormElem.dataset.isEmailAuthenticationEnabled === 'true';
   const registrationMode = loginFormElem.dataset.registrationMode;
   const isPasswordResetEnabled = loginFormElem.dataset.isPasswordResetEnabled === 'true';
 
@@ -69,6 +71,7 @@ if (loginFormElem) {
           name={name}
           email={email}
           isRegistrationEnabled={isRegistrationEnabled}
+          isEmailAuthenticationEnabled={isEmailAuthenticationEnabled}
           registrationMode={registrationMode}
           registrationWhiteList={registrationWhiteList}
           isPasswordResetEnabled={isPasswordResetEnabled}
@@ -111,3 +114,25 @@ if (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: [],
       useOnlyEnvVars: false,
       isPasswordResetEnabled: false,
+      isEmailAuthenticationEnabled: false,
     };
 
   }
@@ -36,6 +37,7 @@ export default class AdminLocalSecurityContainer extends Container {
         registrationMode: localSetting.registrationMode,
         registrationWhiteList: localSetting.registrationWhiteList,
         isPasswordResetEnabled: localSetting.isPasswordResetEnabled,
+        isEmailAuthenticationEnabled: localSetting.isEmailAuthenticationEnabled,
       });
     }
     catch (err) {
@@ -75,15 +77,23 @@ export default class AdminLocalSecurityContainer extends Container {
     this.setState({ isPasswordResetEnabled: !this.state.isPasswordResetEnabled });
   }
 
+  /**
+   * Switch email authentication enabled
+   */
+  switchIsEmailAuthenticationEnabled() {
+    this.setState({ isEmailAuthenticationEnabled: !this.state.isEmailAuthenticationEnabled });
+  }
+
   /**
    * update local security setting
    */
   async updateLocalSecuritySetting() {
-    const { registrationWhiteList, isPasswordResetEnabled } = this.state;
+    const { registrationWhiteList, isPasswordResetEnabled, isEmailAuthenticationEnabled } = this.state;
     const response = await this.appContainer.apiv3.put('/security-setting/local-setting', {
       registrationMode: this.state.registrationMode,
       registrationWhiteList,
       isPasswordResetEnabled,
+      isEmailAuthenticationEnabled,
     });
 
     const { localSettingParams } = response.data;
@@ -92,6 +102,7 @@ export default class AdminLocalSecurityContainer extends Container {
       registrationMode: localSettingParams.registrationMode,
       registrationWhiteList: localSettingParams.registrationWhiteList,
       isPasswordResetEnabled: localSettingParams.isPasswordResetEnabled,
+      isEmailAuthenticationEnabled: localSettingParams.isEmailAuthenticationEnabled,
     });
 
     return localSettingParams;

+ 20 - 2
packages/app/src/client/services/ContextExtractor.tsx

@@ -6,10 +6,13 @@ import {
   useIsDeletable, useIsDeleted, useIsNotCreatable, useIsPageExist, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   usePageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser,
+  useSlackChannels,
 } from '../../stores/context';
 import {
-  useIsDeviceSmallerThanMd, usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser,
+  useIsDeviceSmallerThanMd,
+  usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
 } from '~/stores/ui';
+import { IUserUISettings } from '~/interfaces/user-ui-settings';
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
 
@@ -24,6 +27,11 @@ const ContextExtractorOnce: FC = () => {
    */
   const currentUser = JSON.parse(document.getElementById('growi-current-user')?.textContent || jsonNull);
 
+  /*
+   * UserUISettings from DOM
+   */
+  const userUISettings: Partial<IUserUISettings> = JSON.parse(document.getElementById('growi-user-ui-settings')?.textContent || jsonNull);
+
   /*
    * Page Context from DOM
    */
@@ -56,13 +64,20 @@ const ContextExtractorOnce: FC = () => {
   const hasDraftOnHackmd = !!mainContent?.getAttribute('data-page-has-draft-on-hackmd');
   const creator = JSON.parse(mainContent?.getAttribute('data-page-creator') || jsonNull);
   const revisionAuthor = JSON.parse(mainContent?.getAttribute('data-page-revision-author') || jsonNull);
-
+  const slackChannels = mainContent?.getAttribute('data-slack-channels') || '';
   /*
    * use static swr
    */
   // App
   useCurrentUser(currentUser);
 
+  // UserUISettings
+  usePreferDrawerModeByUser(userUISettings?.preferDrawerModeByUser);
+  usePreferDrawerModeOnEditByUser(userUISettings?.preferDrawerModeOnEditByUser);
+  useSidebarCollapsed(userUISettings?.isSidebarCollapsed);
+  useCurrentSidebarContents(userUISettings?.currentSidebarContents);
+  useCurrentProductNavWidth(userUISettings?.currentProductNavWidth);
+
   // Page
   useCurrentCreatedAt(createdAt);
   useDeleteUsername(deleteUsername);
@@ -96,6 +111,9 @@ const ContextExtractorOnce: FC = () => {
   usePreferDrawerModeOnEditByUser();
   useIsDeviceSmallerThanMd();
 
+  // Editor
+  useSlackChannels(slackChannels);
+
   return null;
 };
 

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

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

+ 47 - 0
packages/app/src/client/util/codemirror/drawio-fold.ext.js

@@ -0,0 +1,47 @@
+/* eslint-disable */
+
+import mdu from '../../../components/PageEditor/MarkdownDrawioUtil.js';
+
+(function(mod) {
+  mod(require("codemirror"));
+})(function(CodeMirror) {
+  "use strict"
+
+  CodeMirror.registerGlobalHelper('fold', 'drawio', function (mode, cm) {
+    return true;
+  }, function(cm, start) {
+    function isBeginningOfDrawio(lineNo) {
+      let line = cm.getLine(lineNo);
+      let match = mdu.lineBeginPartOfDrawioRE.exec(line);
+      if (match) {
+        return true;
+      }
+      return false;
+    }
+    function isEndOfDrawio(lineNo) {
+      let line = cm.getLine(lineNo);
+      let match = mdu.lineEndPartOfDrawioRE.exec(line);
+      if (match) {
+        return true;
+      }
+      return false;
+    }
+
+    let drawio = isBeginningOfDrawio(start.line);
+    if (drawio === false) { return; }
+
+    let lastLine = cm.lastLine();
+    let end = start.line;
+    while(end < lastLine) {
+      end += 1;
+      if (isEndOfDrawio(end)) {
+        break;
+      }
+    }
+
+    return {
+      from: CodeMirror.Pos(start.line, cm.getLine(start.line).length),
+      to: CodeMirror.Pos(end, cm.getLine(end).length)
+    };
+  });
+});

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

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

+ 1 - 1
packages/app/src/client/util/interceptor/detach-code-blocks.js

@@ -1,4 +1,4 @@
-import { BasicInterceptor } from 'growi-commons';
+import { BasicInterceptor } from '@growi/core';
 
 import loggerFactory from '~/utils/logger';
 

+ 17 - 2
packages/app/src/client/util/interceptor/drawio-interceptor.js

@@ -2,7 +2,7 @@
 import React from 'react';
 import ReactDOM from 'react-dom';
 import { Provider } from 'unstated';
-import { BasicInterceptor } from 'growi-commons';
+import { BasicInterceptor } from '@growi/core';
 
 import Drawio from '~/components/Drawio';
 
@@ -103,11 +103,18 @@ export class DrawioInterceptor extends BasicInterceptor {
    */
   drawioPostRender(contextName, context) {
     const isPreview = (contextName === 'postRenderPreviewHtml');
+    const editorContainer = this.appContainer.getContainer('EditorContainer');
+    const renderDrawioInRealtime = editorContainer.state.previewOptions.renderDrawioInRealtime;
 
     Object.keys(context.DrawioMap).forEach((domId) => {
       const elem = document.getElementById(domId);
       if (elem) {
-        this.renderReactDOM(context.DrawioMap[domId], elem, isPreview);
+        if (isPreview && !renderDrawioInRealtime) {
+          this.renderDisabledDrawioReactDOM(context.DrawioMap[domId], elem, isPreview);
+        }
+        else {
+          this.renderReactDOM(context.DrawioMap[domId], elem, isPreview);
+        }
       }
     });
   }
@@ -129,6 +136,14 @@ export class DrawioInterceptor extends BasicInterceptor {
     );
   }
 
+  renderDisabledDrawioReactDOM(drawioMapEntry, elem, isPreview) {
+    ReactDOM.render(
+      // eslint-disable-next-line react/jsx-filename-extension
+      <div className="alert alert-light text-dark">Rendering of draw.io is disabled.</div>,
+      elem,
+    );
+  }
+
   /**
    * @inheritdoc
    */

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

@@ -31,7 +31,7 @@ class AppSettingsPageContents extends React.Component {
 
         <div className="row mt-5">
           <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 />
           </div>
         </div>

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

@@ -31,9 +31,15 @@ class LocalSecuritySettingContents extends React.Component {
   }
 
   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 { isMailerSetup } = appContainer.config;
 
     return (
       <React.Fragment>
@@ -46,6 +52,17 @@ class LocalSecuritySettingContents extends React.Component {
         )}
         <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 && (
           <p
             className="alert alert-info"
@@ -178,6 +195,33 @@ class LocalSecuritySettingContents extends React.Component {
               </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="offset-3 col-6">
                 <button

+ 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 {
       t,
       appContainer,
+      isEmailAuthenticationEnabled,
       username,
       name,
       email,
@@ -155,6 +156,15 @@ class LoginForm extends React.Component {
       registrationWhiteList,
     } = 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 (
       <React.Fragment>
         {registrationMode === 'Restricted' && (
@@ -164,27 +174,44 @@ class LoginForm extends React.Component {
             {t('page_register.notice.restricted_defail')}
           </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>
+        )}
 
-          <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>
-            <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-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>
-            <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">
             <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>
               <span className="btn-label">
                 <i className="icon-user-follow"></i>
               </span>
-              <span className="btn-label-text">{t('Sign up')}</span>
+              <span className="btn-label-text">{submitText}</span>
             </button>
           </div>
         </form>
@@ -314,6 +345,7 @@ LoginForm.propTypes = {
   registrationMode: PropTypes.string,
   registrationWhiteList: PropTypes.array,
   isPasswordResetEnabled: PropTypes.bool,
+  isEmailAuthenticationEnabled: PropTypes.bool,
   isLocalStrategySetup: PropTypes.bool,
   isLdapStrategySetup: PropTypes.bool,
   objOfIsExternalAuthEnableds: PropTypes.object,

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

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

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

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

+ 23 - 5
packages/app/src/components/PageEditor.jsx

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

+ 32 - 3
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -66,6 +66,7 @@ require('codemirror/addon/display/placeholder');
 require('codemirror/addon/lint/lint');
 require('codemirror/addon/lint/lint.css');
 require('~/client/util/codemirror/autorefresh.ext');
+require('~/client/util/codemirror/drawio-fold.ext');
 require('~/client/util/codemirror/gfm-growi.mode');
 // import modes to highlight
 require('codemirror/mode/clike/clike');
@@ -149,6 +150,9 @@ export default class CodeMirrorEditor extends AbstractEditor {
     this.showLinkEditHandler = this.showLinkEditHandler.bind(this);
     this.showHandsonTableHandler = this.showHandsonTableHandler.bind(this);
     this.showDrawioHandler = this.showDrawioHandler.bind(this);
+
+    this.foldDrawioSection = this.foldDrawioSection.bind(this);
+    this.onSaveForDrawio = this.onSaveForDrawio.bind(this);
   }
 
   init() {
@@ -185,6 +189,9 @@ export default class CodeMirrorEditor extends AbstractEditor {
     // set keymap
     const keymapMode = this.props.editorOptions.keymapMode;
     this.setKeymapMode(keymapMode);
+
+    // fold drawio section
+    this.foldDrawioSection();
   }
 
   componentWillReceiveProps(nextProps) {
@@ -195,6 +202,9 @@ export default class CodeMirrorEditor extends AbstractEditor {
     // set keymap
     const keymapMode = nextProps.editorOptions.keymapMode;
     this.setKeymapMode(keymapMode);
+
+    // fold drawio section
+    this.foldDrawioSection();
   }
 
   async initializeTextlint() {
@@ -264,7 +274,10 @@ export default class CodeMirrorEditor extends AbstractEditor {
     const linePosition = Math.max(0, line);
 
     editor.setCursor({ line: linePosition }); // leave 'ch' field as null/undefined to indicate the end of line
-    this.setScrollTopByLine(linePosition);
+
+    setTimeout(() => {
+      this.setScrollTopByLine(linePosition);
+    }, 100);
   }
 
   /**
@@ -277,7 +290,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
     const editor = this.getCodeMirror();
     // get top position of the line
-    const top = editor.charCoords({ line, ch: 0 }, 'local').top;
+    const top = editor.charCoords({ line: line - 1, ch: 0 }, 'local').top;
     editor.scrollTo(null, top);
   }
 
@@ -738,6 +751,22 @@ export default class CodeMirrorEditor extends AbstractEditor {
     this.drawioModal.current.show(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()));
   }
 
+  // fold draw.io section (::: drawio ~ :::)
+  foldDrawioSection() {
+    const editor = this.getCodeMirror();
+    const lineNumbers = mdu.findAllDrawioSection(editor);
+    lineNumbers.forEach((lineNumber) => {
+      editor.foldCode({ line: lineNumber, ch: 0 }, { scanUp: false }, 'fold');
+    });
+  }
+
+  onSaveForDrawio(drawioData) {
+    const range = mdu.replaceFocusedDrawioWithEditor(this.getCodeMirror(), drawioData);
+    // Fold the section after the drawio section (:::drawio) has been updated.
+    this.foldDrawioSection();
+    return range;
+  }
+
   getNavbarItems() {
     return [
       <Button
@@ -971,7 +1000,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         />
         <DrawioModal
           ref={this.drawioModal}
-          onSave={(drawioData) => { return mdu.replaceFocusedDrawioWithEditor(this.getCodeMirror(), drawioData) }}
+          onSave={this.onSaveForDrawio}
         />
 
       </React.Fragment>

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

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

+ 16 - 0
packages/app/src/components/PageEditor/MarkdownDrawioUtil.js

@@ -155,6 +155,22 @@ class MarkdownDrawioUtil {
     return newMarkdown;
   }
 
+  /**
+   * return an array of the starting line numbers of the drawio sections found in markdown
+   */
+  findAllDrawioSection(editor) {
+    const lineNumbers = [];
+    // refs: https://github.com/codemirror/CodeMirror/blob/5.64.0/addon/fold/foldcode.js#L106-L111
+    for (let i = editor.firstLine(), e = editor.lastLine(); i <= e; i++) {
+      const line = editor.getLine(i);
+      const match = this.lineBeginPartOfDrawioRE.exec(line);
+      if (match) {
+        lineNumbers.push(i);
+      }
+    }
+    return lineNumbers;
+  }
+
 }
 
 // singleton pattern

+ 1 - 1
packages/app/src/components/PageEditor/MarkdownTableInterceptor.js

@@ -1,4 +1,4 @@
-import { BasicInterceptor } from 'growi-commons';
+import { BasicInterceptor } from '@growi/core';
 
 import mtu from './MarkdownTableUtil';
 import MarkdownTable from '~/client/models/MarkdownTable';

+ 36 - 0
packages/app/src/components/PageEditor/OptionsSelector.jsx

@@ -22,6 +22,7 @@ export const defaultEditorOptions = {
 
 export const defaultPreviewOptions = {
   renderMathJaxInRealtime: false,
+  renderDrawioInRealtime: true,
 };
 
 class OptionsSelector extends React.Component {
@@ -54,6 +55,7 @@ class OptionsSelector extends React.Component {
     this.onChangeKeymapMode = this.onChangeKeymapMode.bind(this);
     this.onClickStyleActiveLine = this.onClickStyleActiveLine.bind(this);
     this.onClickRenderMathJaxInRealtime = this.onClickRenderMathJaxInRealtime.bind(this);
+    this.onClickRenderDrawioInRealtime = this.onClickRenderDrawioInRealtime.bind(this);
     this.onClickMarkdownTableAutoFormatting = this.onClickMarkdownTableAutoFormatting.bind(this);
     this.switchTextlintEnabledHandler = this.switchTextlintEnabledHandler.bind(this);
     this.confirmEnableTextlintHandler = this.confirmEnableTextlintHandler.bind(this);
@@ -108,6 +110,17 @@ class OptionsSelector extends React.Component {
     editorContainer.saveOptsToLocalStorage();
   }
 
+  onClickRenderDrawioInRealtime(event) {
+    const { editorContainer } = this.props;
+
+    const newValue = !editorContainer.state.previewOptions.renderDrawioInRealtime;
+    const newOpts = Object.assign(editorContainer.state.previewOptions, { renderDrawioInRealtime: newValue });
+    editorContainer.setState({ previewOptions: newOpts });
+
+    // save to localStorage
+    editorContainer.saveOptsToLocalStorage();
+  }
+
   onClickMarkdownTableAutoFormatting(event) {
     const { editorContainer } = this.props;
 
@@ -249,6 +262,7 @@ class OptionsSelector extends React.Component {
           <DropdownMenu>
             {this.renderActiveLineMenuItem()}
             {this.renderRealtimeMathJaxMenuItem()}
+            {this.renderRealtimeDrawioMenuItem()}
             {this.renderMarkdownTableAutoFormattingMenuItem()}
             {this.renderIsTextlintEnabledMenuItem()}
             {/* <DropdownItem divider /> */}
@@ -308,6 +322,28 @@ class OptionsSelector extends React.Component {
     );
   }
 
+  renderRealtimeDrawioMenuItem() {
+    const { editorContainer } = this.props;
+
+    const isActive = editorContainer.state.previewOptions.renderDrawioInRealtime;
+
+    const iconClasses = ['text-info'];
+    if (isActive) {
+      iconClasses.push('ti-check');
+    }
+    const iconClassName = iconClasses.join(' ');
+
+    return (
+      <DropdownItem toggle={false} onClick={this.onClickRenderDrawioInRealtime}>
+        <div className="d-flex justify-content-between">
+          <span className="icon-container"><img src="/images/icons/fx.svg" width="14px" alt="fx"></img></span>
+          <span className="menuitem-label">draw.io Rendering</span>
+          <span className="icon-container"><i className={iconClassName}></i></span>
+        </div>
+      </DropdownItem>
+    );
+  }
+
   renderMarkdownTableAutoFormattingMenuItem() {
     const { t, editorContainer } = this.props;
     // Auto-formatting was enabled before optionalizing, so we made it a disabled option(ignoreMarkdownTableAutoFormatting).

+ 1 - 1
packages/app/src/components/PageEditor/PreventMarkdownListInterceptor.js

@@ -1,4 +1,4 @@
-import { BasicInterceptor } from 'growi-commons';
+import { BasicInterceptor } from '@growi/core';
 
 import mlu from './MarkdownListUtil';
 

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

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

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

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

+ 3 - 8
packages/app/src/components/SearchTypeahead.jsx

@@ -7,7 +7,7 @@ import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
 
 import AppContainer from '~/client/services/AppContainer';
-import { withUnstatedContainers } from './UnstatedUtils';
+import { apiGet } from '~/client/util/apiv1-client';
 
 class SearchTypeahead extends React.Component {
 
@@ -80,7 +80,7 @@ class SearchTypeahead extends React.Component {
 
     this.setState({ isLoading: true });
 
-    this.props.appContainer.apiGet('/search', { q: keyword })
+    apiGet('/search', { q: keyword })
       .then((res) => { this.onSearchSuccess(res) })
       .catch((err) => { this.onSearchError(err) });
   }
@@ -229,11 +229,6 @@ class SearchTypeahead extends React.Component {
 
 }
 
-/**
- * Wrapper component for using unstated
- */
-const SearchTypeaheadWrapper = withUnstatedContainers(SearchTypeahead, [AppContainer]);
-
 /**
  * Properties
  */
@@ -271,4 +266,4 @@ SearchTypeahead.defaultProps = {
   onInputChange: () => {},
 };
 
-export default SearchTypeaheadWrapper;
+export default SearchTypeahead;

+ 13 - 72
packages/app/src/components/Sidebar.tsx

@@ -43,20 +43,9 @@ const GlobalNavigation = () => {
   return <SidebarNav onItemSelected={itemSelectedHandler} />;
 };
 
-// dummy skelton contents
-const GlobalNavigationSkelton = () => {
-  return (
-    <div className="grw-sidebar-nav">
-      <div className="grw-sidebar-nav-primary-container">
-      </div>
-      <div className="grw-sidebar-nav-secondary-container">
-      </div>
-    </div>
-  );
-};
-
-
 const SidebarContentsWrapper = () => {
+  const [resetKey, setResetKey] = useState(0);
+
   const scrollTargetSelector = '#grw-sidebar-contents-scroll-target';
 
   const calcViewHeight = useCallback(() => {
@@ -73,10 +62,11 @@ const SidebarContentsWrapper = () => {
         contentsElemSelector="#grw-sidebar-content-container"
         stickyElemSelector=".grw-sidebar"
         calcViewHeightFunc={calcViewHeight}
+        resetKey={resetKey}
       />
 
-      <div id="grw-sidebar-contents-scroll-target">
-        <div id="grw-sidebar-content-container">
+      <div id="grw-sidebar-contents-scroll-target" style={{ minHeight: '100%' }}>
+        <div id="grw-sidebar-content-container" onLoad={() => setResetKey(Math.random())}>
           <SidebarContents />
         </div>
       </div>
@@ -86,13 +76,6 @@ const SidebarContentsWrapper = () => {
   );
 };
 
-// dummy skelton contents
-const SidebarSkeltonContents = () => {
-  return (
-    <div>Skelton Contents!!!</div>
-  );
-};
-
 
 type Props = {
 }
@@ -104,26 +87,12 @@ const Sidebar: FC<Props> = (props: Props) => {
   const { data: isCollapsed, mutate: mutateSidebarCollapsed } = useSidebarCollapsed();
   const { data: isResizeDisabled, mutate: mutateSidebarResizeDisabled } = useSidebarResizeDisabled();
 
+  const [isTransitionEnabled, setTransitionEnabled] = useState(false);
+
   const [isHover, setHover] = useState(false);
   const [isDragging, setDrag] = useState(false);
-  const [isMounted, setMounted] = useState(false);
 
   const isResizableByDrag = !isResizeDisabled && !isDrawerMode && (!isCollapsed || isHover);
-  /**
-   * hack and override UIController.storeState
-   *
-   * Since UIController is an unstated container, setState() in storeState method should be awaited before writing to cache.
-   */
-  // hackUIController() {
-  //   const { navigationUIController } = this.props;
-
-  //   // see: @atlaskit/navigation-next/dist/esm/ui-controller/UIController.js
-  //   const orgStoreState = navigationUIController.storeState;
-  //   navigationUIController.storeState = async(state) => {
-  //     await navigationUIController.setState(state);
-  //     orgStoreState(state);
-  //   };
-  // }
 
   const toggleDrawerMode = useCallback((bool) => {
     const isStateModified = isResizeDisabled !== bool;
@@ -133,52 +102,24 @@ const Sidebar: FC<Props> = (props: Props) => {
 
     // Drawer <-- Dock
     if (bool) {
-      // // cache state
-      // this.sidebarCollapsedCached = navigationUIController.state.isCollapsed;
-      // this.sidebarWidthCached = navigationUIController.state.productNavWidth;
-
-      // // clear transition temporary
-      // if (this.sidebarCollapsedCached) {
-      //   this.addCssClassTemporary('grw-sidebar-supress-transitions-to-drawer');
-      // }
-
       // disable resize
       mutateSidebarResizeDisabled(true, false);
     }
     // Drawer --> Dock
     else {
-      // // clear transition temporary
-      // if (this.sidebarCollapsedCached) {
-      //   this.addCssClassTemporary('grw-sidebar-supress-transitions-to-dock');
-      // }
-
       // enable resize
       mutateSidebarResizeDisabled(false, false);
-
-      // // restore width
-      // if (this.sidebarWidthCached != null) {
-      //   navigationUIController.setState({ productNavWidth: this.sidebarWidthCached });
-      // }
     }
   }, [isResizeDisabled, mutateSidebarResizeDisabled]);
 
-  // addCssClassTemporary(className) {
-  //   // clear
-  //   this.sidebarElem.classList.add(className);
-
-  //   // restore after 300ms
-  //   setTimeout(() => {
-  //     this.sidebarElem.classList.remove(className);
-  //   }, 300);
-  // }
-
   const backdropClickedHandler = useCallback(() => {
     mutateDrawerOpened(false, false);
   }, [mutateDrawerOpened]);
 
   useEffect(() => {
-    // this.hackUIController();
-    setMounted(true);
+    setTimeout(() => {
+      setTransitionEnabled(true);
+    }, 1000);
   }, []);
 
   useEffect(() => {
@@ -285,10 +226,10 @@ const Sidebar: FC<Props> = (props: Props) => {
     <>
       <div className={`grw-sidebar d-print-none ${isDrawerMode ? 'grw-sidebar-drawer' : ''} ${isDrawerOpened ? 'open' : ''}`}>
         <div className="data-layout-container">
-          <div className="navigation" onMouseLeave={hoverOutHandler}>
+          <div className={`navigation ${isTransitionEnabled ? 'transition-enabled' : ''}`} onMouseLeave={hoverOutHandler}>
             <div className="grw-navigation-wrap">
               <div className="grw-global-navigation">
-                { isMounted ? <GlobalNavigation></GlobalNavigation> : <GlobalNavigationSkelton></GlobalNavigationSkelton> }
+                <GlobalNavigation></GlobalNavigation>
               </div>
               <div
                 ref={resizableContainer}
@@ -298,7 +239,7 @@ const Sidebar: FC<Props> = (props: Props) => {
               >
                 <div className="grw-contextual-navigation-child">
                   <div role="group" className={`grw-contextual-navigation-sub ${!isHover && isCollapsed ? 'collapsed' : ''}`}>
-                    { isMounted ? <SidebarContentsWrapper></SidebarContentsWrapper> : <SidebarSkeltonContents></SidebarSkeltonContents> }
+                    <SidebarContentsWrapper></SidebarContentsWrapper>
                   </div>
                 </div>
               </div>

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

@@ -6,6 +6,7 @@ import { useSWRxPageByPath } from '~/stores/page';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import RevisionRenderer from '../Page/RevisionRenderer';
+import { IRevision } from '~/interfaces/revision';
 
 const logger = loggerFactory('growi:cli:CustomSidebar');
 
@@ -33,7 +34,7 @@ const CustomSidebar: FC<Props> = (props: Props) => {
   const { data: page, mutate } = useSWRxPageByPath('/Sidebar');
 
   const isLoading = page === undefined;
-  const markdown = page?.revision?.body;
+  const markdown = (page?.revision as IRevision | undefined)?.body;
 
   return (
     <>

+ 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 - 3
packages/app/src/components/StickyStretchableScroller.jsx

@@ -48,6 +48,7 @@ const StickyStretchableScroller = (props) => {
   const {
     children, contentsElemSelector, stickyElemSelector,
     calcViewHeightFunc, calcContentsHeightFunc,
+    resetKey,
   } = props;
 
   if (scrollTargetSelector == null && children == null) {
@@ -137,10 +138,12 @@ const StickyStretchableScroller = (props) => {
     };
   }, [resetScrollbarDebounced]);
 
-  // setup effect by update props
+  // setup effect on init
   useEffect(() => {
-    resetScrollbarDebounced();
-  }, [resetScrollbarDebounced]);
+    if (resetKey != null) {
+      resetScrollbarDebounced();
+    }
+  }, [resetKey, resetScrollbarDebounced]);
 
   return (
     <>
@@ -156,6 +159,8 @@ StickyStretchableScroller.propTypes = {
   scrollTargetSelector: PropTypes.string,
   stickyElemSelector: PropTypes.string,
 
+  resetKey: PropTypes.any,
+
   calcViewHeightFunc: PropTypes.func,
   calcContentsHeightFunc: PropTypes.func,
 };

+ 7 - 0
packages/app/src/interfaces/common.ts

@@ -0,0 +1,7 @@
+/*
+ * Common types and interfaces
+ */
+
+
+// Foreign key field
+export type Ref<T> = string | T;

+ 14 - 0
packages/app/src/interfaces/named-query.ts

@@ -0,0 +1,14 @@
+import { IUser } from './user';
+
+
+export const SearchDelegatorName = {
+  DEFAULT: 'FullTextSearch',
+} as const;
+export type SearchDelegatorName = typeof SearchDelegatorName[keyof typeof SearchDelegatorName];
+
+export interface INamedQuery {
+  name: string
+  aliasOf?: string
+  delegatorName?: SearchDelegatorName
+  creator?: IUser
+}

+ 26 - 4
packages/app/src/interfaces/page.ts

@@ -1,14 +1,36 @@
+import { Ref } from './common';
 import { IUser } from './user';
 import { IRevision } from './revision';
 import { ITag } from './tag';
+import { HasObjectId } from './has-object-id';
+
 
 export type IPage = {
   path: string,
   status: string,
-  revision: IRevision,
-  tags: ITag[],
-  creator: IUser,
+  revision: Ref<IRevision>,
+  tags: Ref<ITag>[],
+  creator: Ref<IUser>,
   createdAt: Date,
   updatedAt: Date,
-  seenUsers: string[]
+  seenUsers: Ref<IUser>[],
+  parent: Ref<IPage> | null,
+  isEmpty: boolean,
+  redirectTo: string,
+  grant: number,
+  grantedUsers: Ref<IUser>[],
+  grantedGroup: Ref<any>,
+  lastUpdateUser: Ref<IUser>,
+  liker: Ref<IUser>[],
+  commentCount: number
+  slackChannels: string,
+  pageIdOnHackmd: string,
+  revisionHackmdSynced: Ref<IRevision>,
+  hasDraftOnHackmd: boolean,
+  deleteUser: Ref<IUser>,
+  deletedAt: Date,
 }
+
+export type IPageHasId = IPage & HasObjectId;
+
+export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean}>;

+ 31 - 0
packages/app/src/interfaces/search.ts

@@ -0,0 +1,31 @@
+import { IPageHasId } from './page';
+
+export enum CheckboxType {
+  NONE_CHECKED = 'noneChecked',
+  INDETERMINATE = 'indeterminate',
+  ALL_CHECKED = 'allChecked',
+}
+
+export type IPageSearchResultData = {
+  pageData: IPageHasId,
+  pageMeta: {
+    bookmarkCount?: number,
+    elasticSearchResult?: {
+      snippet: string,
+      highlightedPath: string,
+    },
+  },
+}
+
+export const SORT_AXIS = {
+  RELATION_SCORE: 'relationScore',
+  CREATED_AT: 'createdAt',
+  UPDATED_AT: 'updatedAt',
+} as const;
+export type SORT_AXIS = typeof SORT_AXIS[keyof typeof SORT_AXIS];
+
+export const SORT_ORDER = {
+  DESC: 'desc',
+  ASC: 'asc',
+} as const;
+export type SORT_ORDER = typeof SORT_ORDER[keyof typeof SORT_ORDER];

+ 1 - 1
packages/app/src/server/crowi/index.js

@@ -21,6 +21,7 @@ import AclService from '../service/acl';
 import AttachmentService from '../service/attachment';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
+import SearchService from '../service/search';
 
 const logger = loggerFactory('growi:crowi');
 const httpErrorHandler = require('../middlewares/http-error-handler');
@@ -371,7 +372,6 @@ Crowi.prototype.setupPassport = async function() {
 };
 
 Crowi.prototype.setupSearcher = async function() {
-  const SearchService = require('~/server/service/search');
   this.searchService = new SearchService(this);
 };
 

+ 45 - 0
packages/app/src/server/interfaces/search.ts

@@ -0,0 +1,45 @@
+/* eslint-disable camelcase */
+import { SearchDelegatorName } from '~/interfaces/named-query';
+
+
+export type QueryTerms = {
+  match: string[],
+  not_match: string[],
+  phrase: string[],
+  not_phrase: string[],
+  prefix: string[],
+  not_prefix: string[],
+  tag: string[],
+  not_tag: string[],
+}
+
+export type ParsedQuery = { queryString: string, terms?: QueryTerms, delegatorName?: string }
+
+export interface SearchQueryParser {
+  parseSearchQuery(queryString: string): Promise<ParsedQuery>
+}
+
+export interface SearchResolver{
+  resolve(parsedQuery: ParsedQuery): Promise<[SearchDelegator, SearchableData | null]>
+}
+
+export interface SearchDelegator<T = unknown> {
+  name?: SearchDelegatorName
+  search(data: SearchableData | null, user, userGroups, option): Promise<Result<T> & MetaData>
+}
+
+export type Result<T> = {
+  data: T[]
+}
+
+export type MetaData = {
+  meta: {
+    [key:string]: any,
+    total: number,
+  }
+}
+
+export type SearchableData = {
+  queryString: string
+  terms: QueryTerms
+}

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

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

@@ -0,0 +1,35 @@
+import { IUserUISettings } from '~/interfaces/user-ui-settings';
+import loggerFactory from '~/utils/logger';
+
+import UserUISettings from '../models/user-ui-settings';
+
+const logger = loggerFactory('growi:middleware:inject-user-ui-settings-to-localvars');
+
+async function getSettings(userId: string): Promise<Partial<IUserUISettings> | null> {
+  const doc = await UserUISettings.findOne({ user: userId }).exec();
+
+  let partialDoc: Partial<IUserUISettings> | null = null;
+  if (doc != null) {
+    partialDoc = doc.toObject();
+    delete partialDoc.user;
+  }
+
+  return partialDoc;
+}
+
+module.exports = () => {
+  return async(req, res, next) => {
+    if (req.user == null) {
+      return next();
+    }
+
+    try {
+      res.locals.userUISettings = await getSettings(req.user._id);
+    }
+    catch (err: unknown) {
+      logger.error(err);
+    }
+
+    next();
+  };
+};

+ 1 - 1
packages/app/src/server/models/GlobalNotificationSetting/index.js

@@ -1,6 +1,6 @@
 const mongoose = require('mongoose');
 const nodePath = require('path');
-const { pathUtils } = require('growi-commons');
+const { pathUtils } = require('@growi/core');
 
 /**
  * parent schema for GlobalNotificationSetting model

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

@@ -0,0 +1,36 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import mongoose, {
+  Schema, Model, Document,
+} from 'mongoose';
+
+import { getOrCreateModel } from '@growi/core';
+import loggerFactory from '../../utils/logger';
+import { INamedQuery, SearchDelegatorName } from '~/interfaces/named-query';
+
+const logger = loggerFactory('growi:models:named-query');
+
+export interface NamedQueryDocument extends INamedQuery, Document {}
+
+export type NamedQueryModel = Model<NamedQueryDocument>
+
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
+const schema = new Schema<NamedQueryDocument, NamedQueryModel>({
+  name: { type: String, required: true, unique: true },
+  aliasOf: { type: String },
+  delegatorName: { type: String, enum: SearchDelegatorName },
+  creator: {
+    type: ObjectId, ref: 'User', index: true, default: null,
+  },
+});
+
+schema.pre('validate', async function(this, next) {
+  if (this.aliasOf == null && this.delegatorName == null) {
+    throw Error('Either of aliasOf and delegatorNameName must not be null.');
+  }
+
+  next();
+});
+
+export default getOrCreateModel<NamedQueryDocument, NamedQueryModel>('NamedQuery', schema);

+ 1 - 1
packages/app/src/server/models/page.js

@@ -14,7 +14,7 @@ const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
 const differenceInYears = require('date-fns/differenceInYears');
 
-const { pathUtils } = require('growi-commons');
+const { pathUtils } = require('@growi/core');
 const escapeStringRegexp = require('escape-string-regexp');
 
 const { isTopPage, isTrashPage } = pagePathUtils;

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

+ 2 - 2
packages/app/src/server/models/user-ui-settings.ts

@@ -12,7 +12,7 @@ export interface UserUISettingsDocument extends IUserUISettings, Document {}
 export type UserUISettingsModel = Model<UserUISettingsDocument>
 
 const schema = new Schema<UserUISettingsDocument, UserUISettingsModel>({
-  user: { type: Schema.Types.ObjectId, ref: 'User', index: true },
+  user: { type: Schema.Types.ObjectId, ref: 'User', unique: true },
   isSidebarCollapsed: { type: Boolean, default: false },
   currentSidebarContents: {
     type: String,
@@ -21,7 +21,7 @@ const schema = new Schema<UserUISettingsDocument, UserUISettingsModel>({
   },
   currentProductNavWidth: { type: Number },
   preferDrawerModeByUser: { type: Boolean, default: false },
-  preferDrawerModeOnEditByUser: { type: Boolean, default: false },
+  preferDrawerModeOnEditByUser: { type: Boolean, default: true },
 });
 
 

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

@@ -483,6 +483,16 @@ module.exports = function(crowi) {
     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) {
     const User = this;
     let emailUsable = true;

+ 1 - 1
packages/app/src/server/routes/apiv3/app-settings.js

@@ -7,7 +7,7 @@ const debug = require('debug')('growi:routes:admin');
 
 const express = require('express');
 
-const { pathUtils } = require('growi-commons');
+const { pathUtils } = require('@growi/core');
 const { listLocaleIds } = require('~/utils/locale-utils');
 
 const router = express.Router();

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

@@ -1,4 +1,6 @@
 import loggerFactory from '~/utils/logger';
+import * as userActivation from './user-activation';
+import injectUserRegistrationOrderByTokenMiddleware from '../../middlewares/inject-user-registration-order-by-token-middleware';
 
 const logger = loggerFactory('growi:routes:apiv3'); // eslint-disable-line no-unused-vars
 
@@ -53,7 +55,17 @@ module.exports = (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));
 
+
   return router;
 };

+ 1 - 1
packages/app/src/server/routes/apiv3/pages.js

@@ -3,7 +3,7 @@ import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
 const express = require('express');
-const pathUtils = require('growi-commons').pathUtils;
+const { pathUtils } = require('@growi/core');
 const mongoose = require('mongoose');
 
 const { body } = require('express-validator');

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

+ 0 - 18
packages/app/src/server/routes/apiv3/user-ui-settings.ts

@@ -25,24 +25,6 @@ module.exports = (crowi) => {
     body('settings.preferDrawerModeOnEditByUser').optional().isBoolean(),
   ];
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  router.get('/', loginRequiredStrictly, async(req: any, res: any) => {
-    const { user } = req;
-
-    try {
-      const updatedSettings = await UserUISettings.findOneAndUpdate(
-        { user: user._id },
-        { user: user._id },
-        { upsert: true, new: true },
-      );
-      return res.apiv3(updatedSettings);
-    }
-    catch (err) {
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(err));
-    }
-  });
-
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   router.put('/', loginRequiredStrictly, csrf, validatorForPut, apiV3FormValidator, async(req: any, res: any) => {
     const { user } = req;

+ 25 - 17
packages/app/src/server/routes/index.js

@@ -1,8 +1,10 @@
 import express from 'express';
 
 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 userActivation from './user-activation';
 
 const multer = require('multer');
 const autoReap = require('multer-autoreap');
@@ -27,6 +29,7 @@ module.exports = function(crowi, app) {
   const adminRequired = require('../middlewares/admin-required')(crowi);
   const certifySharedFile = require('../middlewares/certify-shared-file')(crowi);
   const csrf = require('../middlewares/csrf')(crowi);
+  const injectUserUISettings = require('../middlewares/inject-user-ui-settings-to-localvars')();
 
   const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
   const form = require('../form');
@@ -51,7 +54,7 @@ module.exports = function(crowi, app) {
   app.use('/api-docs', require('./apiv3/docs')(crowi));
   app.use('/_api/v3', require('./apiv3')(crowi));
 
-  app.get('/'                         , applicationInstalled, loginRequired , autoReconnectToSearch, page.showTopPage);
+  app.get('/'                         , applicationInstalled, loginRequired, autoReconnectToSearch, injectUserUISettings, page.showTopPage);
 
   app.get('/login/error/:reason'      , applicationInstalled, login.error);
   app.get('/login'                    , applicationInstalled, login.preLogin, login.login);
@@ -131,23 +134,23 @@ module.exports = function(crowi, app) {
   app.get('/admin/export'                       , loginRequiredStrictly , adminRequired ,admin.export.index);
   app.get('/admin/export/:fileName'             , loginRequiredStrictly , adminRequired ,admin.export.api.validators.export.download(), admin.export.download);
 
-  app.get('/admin/*'                       , loginRequiredStrictly ,adminRequired, admin.notFound.index);
+  app.get('/admin/*'                            , loginRequiredStrictly ,adminRequired, admin.notFound.index);
 
-  app.get('/me'                       , loginRequiredStrictly , me.index);
+  app.get('/me'                                 , loginRequiredStrictly, injectUserUISettings, me.index);
   // external-accounts
-  app.get('/me/external-accounts'                         , loginRequiredStrictly , me.externalAccounts.list);
+  app.get('/me/external-accounts'               , loginRequiredStrictly, injectUserUISettings, me.externalAccounts.list);
   // my drafts
-  app.get('/me/drafts'                , loginRequiredStrictly, me.drafts.list);
+  app.get('/me/drafts'                          , loginRequiredStrictly, injectUserUISettings, me.drafts.list);
 
-  app.get('/:id([0-9a-z]{24})'       , loginRequired , page.redirector);
-  app.get('/_r/:id([0-9a-z]{24})'    , loginRequired , page.redirector); // alias
-  app.get('/attachment/:id([0-9a-z]{24})' , certifySharedFile , loginRequired, attachment.api.get);
+  app.get('/:id([0-9a-z]{24})'                  , loginRequired , page.redirector);
+  app.get('/_r/:id([0-9a-z]{24})'               , loginRequired , page.redirector); // alias
+  app.get('/attachment/:id([0-9a-z]{24})'       , certifySharedFile , loginRequired, attachment.api.get);
   app.get('/attachment/profile/:id([0-9a-z]{24})' , loginRequired, attachment.api.get);
-  app.get('/attachment/:pageId/:fileName', loginRequired, attachment.api.obsoletedGetForMongoDB); // DEPRECATED: remains for backward compatibility for v3.3.x or below
-  app.get('/download/:id([0-9a-z]{24})'    , loginRequired, attachment.api.download);
+  app.get('/attachment/:pageId/:fileName'       , loginRequired, attachment.api.obsoletedGetForMongoDB); // DEPRECATED: remains for backward compatibility for v3.3.x or below
+  app.get('/download/:id([0-9a-z]{24})'         , loginRequired, attachment.api.download);
 
-  app.get('/_search'                 , loginRequired , search.searchPage);
-  app.get('/_api/search'             , accessTokenParser , loginRequired , search.api.search);
+  app.get('/_search'                            , loginRequired, injectUserUISettings, search.searchPage);
+  app.get('/_api/search'                        , accessTokenParser , loginRequired , search.api.search);
 
   app.get('/_api/check_username'           , user.api.checkUsername);
   app.get('/_api/me/user-group-relations'  , accessTokenParser , loginRequiredStrictly , me.api.userGroupRelations);
@@ -178,9 +181,9 @@ module.exports = function(crowi, app) {
   app.post('/_api/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , csrf, attachment.api.removeProfileImage);
   app.get('/_api/attachments.limit'   , accessTokenParser , loginRequiredStrictly, attachment.api.limit);
 
-  app.get('/trash$'                   , loginRequired , page.trashPageShowWrapper);
-  app.get('/trash/$'                  , loginRequired , page.trashPageListShowWrapper);
-  app.get('/trash/*/$'                , loginRequired , page.deletedPageListShowWrapper);
+  app.get('/trash$'                   , loginRequired, injectUserUISettings, page.trashPageShowWrapper);
+  app.get('/trash/$'                  , loginRequired, injectUserUISettings, page.trashPageListShowWrapper);
+  app.get('/trash/*/$'                , loginRequired, injectUserUISettings, page.deletedPageListShowWrapper);
 
   app.get('/_hackmd/load-agent'          , hackmd.loadAgent);
   app.get('/_hackmd/load-styles'         , hackmd.loadStyles);
@@ -193,9 +196,14 @@ module.exports = function(crowi, app) {
     .get('/:token', apiLimiter, injectResetOrderByTokenMiddleware, forgotPassword.resetPassword)
     .use(forgotPassword.handleHttpErrosMiddleware));
 
+  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('/*/$'                   , loginRequired , page.showPageWithEndOfSlash, page.notFound);
-  app.get('/*'                     , loginRequired , autoReconnectToSearch, page.showPage, page.notFound);
+  app.get('/*/$'                   , loginRequired, injectUserUISettings, page.showPageWithEndOfSlash, page.notFound);
+  app.get('/*'                     , loginRequired, autoReconnectToSearch, injectUserUISettings, page.showPage, page.notFound);
 
 };

+ 1 - 1
packages/app/src/server/routes/page.js

@@ -137,7 +137,7 @@ module.exports = function(crowi, app) {
   const logger = loggerFactory('growi:routes:page');
   const swig = require('swig-templates');
 
-  const pathUtils = require('growi-commons').pathUtils;
+  const { pathUtils } = require('@growi/core');
 
   const Page = crowi.model('Page');
   const User = crowi.model('User');

+ 20 - 37
packages/app/src/server/routes/search.js

@@ -1,4 +1,6 @@
-const { serializeUserSecurely } = require('../models/serializers/user-serializer');
+const { default: loggerFactory } = require('~/utils/logger');
+
+const logger = loggerFactory('growi:routes:search');
 
 /**
  * @swagger
@@ -110,7 +112,9 @@ module.exports = function(crowi, app) {
    */
   api.search = async function(req, res) {
     const user = req.user;
-    const { q: keyword = null, type = null } = req.query;
+    const {
+      q: keyword = null, type = null, sort = null, order = null,
+    } = req.query;
     let paginateOpts;
 
     try {
@@ -135,48 +139,27 @@ module.exports = function(crowi, app) {
       userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     }
 
-    const searchOpts = { ...paginateOpts, type };
+    const searchOpts = {
+      ...paginateOpts, type, sort, order,
+    };
 
-    const result = {};
+    let searchResult;
+    let delegatorName;
     try {
-      const esResult = await searchService.searchKeyword(keyword, user, userGroups, searchOpts);
-
-      // create score map for sorting
-      // key: id , value: score
-      const scoreMap = {};
-      for (const esPage of esResult.data) {
-        scoreMap[esPage._id] = esPage._score;
-      }
-
-      const ids = esResult.data.map((page) => { return page._id });
-      const findResult = await Page.findListByPageIds(ids);
-
-      // add tag data to result pages
-      findResult.pages.map((page) => {
-        const data = esResult.data.find((data) => { return page.id === data._id });
-        page._doc.tags = data._source.tag_names;
-        return page;
-      });
-
-      result.meta = esResult.meta;
-      result.totalCount = findResult.totalCount;
-      result.data = findResult.pages
-        .map((page) => {
-          if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
-            page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
-          }
-          page.bookmarkCount = (page._source && page._source.bookmark_count) || 0;
-          return page;
-        })
-        .sort((page1, page2) => {
-          // note: this do not consider NaN
-          return scoreMap[page2._id] - scoreMap[page1._id];
-        });
+      [searchResult, delegatorName] = await searchService.searchKeyword(keyword, user, userGroups, searchOpts);
     }
     catch (err) {
+      logger.error('Failed to search', err);
       return res.json(ApiResponse.error(err));
     }
 
+    let result;
+    try {
+      result = await searchService.formatSearchResult(searchResult, delegatorName);
+    }
+    catch (err) {
+      return res.json(ApiResponse.error(err));
+    }
     return res.json(ApiResponse.success(result));
   };
 

+ 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;
 
-  api.checkUsername = function(req, res) {
+  api.checkUsername = async function(req, res) {
     const username = req.query.username;
 
-    User.findUserByUsername(username)
+    let valid = false;
+    await User.findUserByUsername(username)
       .then((userData) => {
         if (userData) {
-          return res.json({ valid: false });
+          valid = false;
+        }
+        else {
+          valid = true;
         }
-
-        return res.json({ valid: true });
       })
       .catch((err) => {
-        return res.json({ valid: true });
+        valid = false;
       });
+    return res.json(ApiResponse.success({ valid }));
   };
 
   /**

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

@@ -1,4 +1,4 @@
-import { envUtils } from 'growi-commons';
+import { envUtils } from '@growi/core';
 
 import loggerFactory from '~/utils/logger';
 
@@ -313,6 +313,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.BOOLEAN,
     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: {
     ns:      'crowi',
     key:     'security:passport-saml:useOnlyEnvVarsForSomeOptions',

+ 119 - 181
packages/app/src/server/service/search-delegator/elasticsearch.js → packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -1,25 +1,58 @@
-import loggerFactory from '~/utils/logger';
+import elasticsearch from 'elasticsearch';
+import mongoose from 'mongoose';
 
-const logger = loggerFactory('growi:service:search-delegator:elasticsearch');
-const elasticsearch = require('elasticsearch');
-const mongoose = require('mongoose');
+import { URL } from 'url';
 
-const { URL } = require('url');
+import { Writable, Transform } from 'stream';
+import streamToPromise from 'stream-to-promise';
 
-const {
-  Writable, Transform,
-} = require('stream');
-const streamToPromise = require('stream-to-promise');
+import { createBatchStream } from '../../util/batch-stream';
+import loggerFactory from '~/utils/logger';
+import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
+import { SearchDelegatorName } from '~/interfaces/named-query';
+import {
+  MetaData, SearchDelegator, Result, SearchableData, QueryTerms,
+} from '../../interfaces/search';
 
-const { createBatchStream } = require('../../util/batch-stream');
+const logger = loggerFactory('growi:service:search-delegator:elasticsearch');
 
 const DEFAULT_OFFSET = 0;
 const DEFAULT_LIMIT = 50;
 const BULK_REINDEX_SIZE = 100;
 
-class ElasticsearchDelegator {
+const { RELATION_SCORE, CREATED_AT, UPDATED_AT } = SORT_AXIS;
+const { DESC, ASC } = SORT_ORDER;
+
+const ES_SORT_AXIS = {
+  [RELATION_SCORE]: '_score',
+  [CREATED_AT]: 'created_at',
+  [UPDATED_AT]: 'updated_at',
+};
+const ES_SORT_ORDER = {
+  [DESC]: 'desc',
+  [ASC]: 'asc',
+};
+
+type Data = any;
+
+class ElasticsearchDelegator implements SearchDelegator<Data> {
+
+  name!: SearchDelegatorName
+
+  configManager!: any
+
+  socketIoService!: any
+
+  client: any
+
+  queries: any
+
+  indexName: string
+
+  esUri: string
 
   constructor(configManager, socketIoService) {
+    this.name = SearchDelegatorName.DEFAULT;
     this.configManager = configManager;
     this.socketIoService = socketIoService;
 
@@ -115,7 +148,7 @@ class ElasticsearchDelegator {
     let esVersion = 'unknown';
     const esNodeInfos = {};
 
-    for (const [nodeName, nodeInfo] of Object.entries(info.nodes)) {
+    for (const [nodeName, nodeInfo] of Object.entries<any>(info.nodes)) {
       esVersion = nodeInfo.version;
 
       const filteredInfo = {
@@ -160,7 +193,7 @@ class ElasticsearchDelegator {
     const isExistsTmpIndex = await client.indices.exists({ index: tmpIndexName });
 
     // create indices name list
-    const existingIndices = [];
+    const existingIndices: string[] = [];
     if (isExistsMainIndex) { existingIndices.push(indexName) }
     if (isExistsTmpIndex) { existingIndices.push(tmpIndexName) }
 
@@ -309,6 +342,7 @@ class ElasticsearchDelegator {
     };
 
     const bookmarkCount = page.bookmarkCount || 0;
+    const seenUsersCount = page.seenUsers.length || 0;
     let document = {
       path: page.path,
       body: page.revision.body,
@@ -317,6 +351,7 @@ class ElasticsearchDelegator {
       comments: page.comments,
       comment_count: page.commentCount,
       bookmark_count: bookmarkCount,
+      seenUsers_count: seenUsersCount,
       like_count: page.liker.length || 0,
       created_at: page.createdAt,
       updated_at: page.updatedAt,
@@ -356,7 +391,7 @@ class ElasticsearchDelegator {
   }
 
   updateOrInsertDescendantsPagesById(page, user) {
-    const Page = mongoose.model('Page');
+    const Page = mongoose.model('Page') as any; // TODO: typescriptize model
     const { PageQueryBuilder } = Page;
     const builder = new PageQueryBuilder(Page.find());
     builder.addConditionToListWithDescendants(page.path);
@@ -366,14 +401,14 @@ class ElasticsearchDelegator {
   /**
    * @param {function} queryFactory factory method to generate a Mongoose Query instance
    */
-  async updateOrInsertPages(queryFactory, option = {}) {
+  async updateOrInsertPages(queryFactory, option: any = {}) {
     const { isEmittingProgressEvent = false, invokeGarbageCollection = false } = option;
 
-    const Page = mongoose.model('Page');
+    const Page = mongoose.model('Page') as any; // TODO: typescriptize model
     const { PageQueryBuilder } = Page;
-    const Bookmark = mongoose.model('Bookmark');
-    const Comment = mongoose.model('Comment');
-    const PageTagRelation = mongoose.model('PageTagRelation');
+    const Bookmark = mongoose.model('Bookmark') as any; // TODO: typescriptize model
+    const Comment = mongoose.model('Comment') as any; // TODO: typescriptize model
+    const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: typescriptize model
 
     const socket = this.socketIoService.getAdminSocket();
 
@@ -552,7 +587,7 @@ class ElasticsearchDelegator {
    *   data: [ pages ...],
    * }
    */
-  async search(query) {
+  async searchKeyword(query) {
     // for debug
     if (process.env.NODE_ENV === 'development') {
       const result = await this.client.indices.validateQuery({
@@ -576,35 +611,24 @@ class ElasticsearchDelegator {
         results: result.hits.hits.length,
       },
       data: result.hits.hits.map((elm) => {
-        return { _id: elm._id, _score: elm._score, _source: elm._source };
+        return {
+          _id: elm._id,
+          _score: elm._score,
+          _source: elm._source,
+          _highlight: elm.highlight,
+        };
       }),
     };
   }
 
-  createSearchQuerySortedByUpdatedAt(option) {
-    // getting path by default is almost for debug
-    let fields = ['path', 'bookmark_count', 'comment_count', 'updated_at', 'tag_names'];
-    if (option) {
-      fields = option.fields || fields;
-    }
-
-    // default is only id field, sorted by updated_at
-    const query = {
-      index: this.aliasName,
-      type: 'pages',
-      body: {
-        sort: [{ updated_at: { order: 'desc' } }],
-        query: {}, // query
-        _source: fields,
-      },
-    };
-    this.appendResultSize(query);
-
-    return query;
-  }
-
-  createSearchQuerySortedByScore(option) {
-    let fields = ['path', 'bookmark_count', 'comment_count', 'updated_at', 'tag_names', 'comments'];
+  /**
+   * create search query for Elasticsearch
+   *
+   * @param {object | undefined} option optional paramas
+   * @returns {object} query object
+   */
+  createSearchQuery(option?) {
+    let fields = ['path', 'bookmark_count', 'comment_count', 'seenUsers_count', 'updated_at', 'tag_names', 'comments'];
     if (option) {
       fields = option.fields || fields;
     }
@@ -614,23 +638,35 @@ class ElasticsearchDelegator {
       index: this.aliasName,
       type: 'pages',
       body: {
-        sort: [{ _score: { order: 'desc' } }],
         query: {}, // query
         _source: fields,
       },
     };
-    this.appendResultSize(query);
 
     return query;
   }
 
-  appendResultSize(query, from, size) {
+  appendResultSize(query, from?, size?) {
     query.from = from || DEFAULT_OFFSET;
     query.size = size || DEFAULT_LIMIT;
   }
 
+  appendSortOrder(query, sortAxis: SORT_AXIS, sortOrder: SORT_ORDER) {
+    // default sort order is score descending
+    const sort = ES_SORT_AXIS[sortAxis] || ES_SORT_AXIS[RELATION_SCORE];
+    const order = ES_SORT_ORDER[sortOrder] || ES_SORT_ORDER[DESC];
+    query.body.sort = { [sort]: { order } };
+  }
+
+  convertSortQuery(sortAxis) {
+    switch (sortAxis) {
+      case RELATION_SCORE:
+        return '_score';
+    }
+  }
+
   initializeBoolQuery(query) {
-    // query is created by createSearchQuerySortedByScore() or createSearchQuerySortedByUpdatedAt()
+    // query is created by createSearchQuery()
     if (!query.body.query.bool) {
       query.body.query.bool = {};
     }
@@ -649,12 +685,9 @@ class ElasticsearchDelegator {
     return query;
   }
 
-  appendCriteriaForQueryString(query, queryString) {
+  appendCriteriaForQueryString(query, parsedKeywords: QueryTerms) {
     query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
 
-    // parse
-    const parsedKeywords = this.parseQueryString(queryString);
-
     if (parsedKeywords.match.length > 0) {
       const q = {
         multi_match: {
@@ -678,7 +711,7 @@ class ElasticsearchDelegator {
     }
 
     if (parsedKeywords.phrase.length > 0) {
-      const phraseQueries = [];
+      const phraseQueries: any[] = [];
       parsedKeywords.phrase.forEach((phrase) => {
         phraseQueries.push({
           multi_match: {
@@ -698,7 +731,7 @@ class ElasticsearchDelegator {
     }
 
     if (parsedKeywords.not_phrase.length > 0) {
-      const notPhraseQueries = [];
+      const notPhraseQueries: any[] = [];
       parsedKeywords.not_phrase.forEach((phrase) => {
         notPhraseQueries.push({
           multi_match: {
@@ -751,12 +784,12 @@ class ElasticsearchDelegator {
 
     query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
 
-    const Page = mongoose.model('Page');
+    const Page = mongoose.model('Page') as any; // TODO: typescriptize model
     const {
       GRANT_PUBLIC, GRANT_RESTRICTED, GRANT_SPECIFIED, GRANT_OWNER, GRANT_USER_GROUP,
     } = Page;
 
-    const grantConditions = [
+    const grantConditions: any[] = [
       { term: { grant: GRANT_PUBLIC } },
     ];
 
@@ -823,44 +856,9 @@ class ElasticsearchDelegator {
     query.body.query.bool.filter.push({ bool: { should: grantConditions } });
   }
 
-  filterPortalPages(query) {
-    query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
-
-    query.body.query.bool.must_not.push(this.queries.USER);
-    query.body.query.bool.filter.push(this.queries.PORTAL);
-  }
-
-  filterPublicPages(query) {
-    query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
-
-    query.body.query.bool.must_not.push(this.queries.USER);
-    query.body.query.bool.filter.push(this.queries.PUBLIC);
-  }
-
-  filterUserPages(query) {
-    query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
-
-    query.body.query.bool.filter.push(this.queries.USER);
-  }
-
-  filterPagesByType(query, type) {
-    const Page = mongoose.model('Page');
-
-    switch (type) {
-      case Page.TYPE_PORTAL:
-        return this.filterPortalPages(query);
-      case Page.TYPE_PUBLIC:
-        return this.filterPublicPages(query);
-      case Page.TYPE_USER:
-        return this.filterUserPages(query);
-      default:
-        return query;
-    }
-  }
-
-  appendFunctionScore(query, queryString) {
+  async appendFunctionScore(query, queryString) {
     const User = mongoose.model('User');
-    const count = User.count({}) || 1;
+    const count = await User.count({}) || 1;
 
     const minScore = queryString.length * 0.1 - 1; // increase with length
     logger.debug('min_score: ', minScore);
@@ -882,99 +880,39 @@ class ElasticsearchDelegator {
     };
   }
 
-  async searchKeyword(queryString, user, userGroups, option) {
+  appendHighlight(query) {
+    query.body.highlight = {
+      fields: {
+        '*': {
+          fragment_size: 40,
+          fragmenter: 'simple',
+          pre_tags: ["<em class='highlighted-keyword'>"],
+          post_tags: ['</em>'],
+        },
+      },
+    };
+  }
+
+  async search(data: SearchableData, user, userGroups, option): Promise<Result<Data> & MetaData> {
+    const { queryString, terms } = data;
+
     const from = option.offset || null;
     const size = option.limit || null;
-    const type = option.type || null;
-    const query = this.createSearchQuerySortedByScore();
-    this.appendCriteriaForQueryString(query, queryString);
+    const sort = option.sort || null;
+    const order = option.order || null;
+    const query = this.createSearchQuery();
+    this.appendCriteriaForQueryString(query, terms);
 
-    this.filterPagesByType(query, type);
     await this.filterPagesByViewer(query, user, userGroups);
 
     this.appendResultSize(query, from, size);
 
-    this.appendFunctionScore(query, queryString);
-
-    return this.search(query);
-  }
-
-  parseQueryString(queryString) {
-    const matchWords = [];
-    const notMatchWords = [];
-    const phraseWords = [];
-    const notPhraseWords = [];
-    const prefixPaths = [];
-    const notPrefixPaths = [];
-    const tags = [];
-    const notTags = [];
-
-    queryString.trim();
-    queryString = queryString.replace(/\s+/g, ' '); // eslint-disable-line no-param-reassign
-
-    // First: Parse phrase keywords
-    const phraseRegExp = new RegExp(/(-?"[^"]+")/g);
-    const phrases = queryString.match(phraseRegExp);
-
-    if (phrases !== null) {
-      queryString = queryString.replace(phraseRegExp, ''); // eslint-disable-line no-param-reassign
-
-      phrases.forEach((phrase) => {
-        phrase.trim();
-        if (phrase.match(/^-/)) {
-          notPhraseWords.push(phrase.replace(/^-/, ''));
-        }
-        else {
-          phraseWords.push(phrase);
-        }
-      });
-    }
+    this.appendSortOrder(query, sort, order);
 
-    // Second: Parse other keywords (include minus keywords)
-    queryString.split(' ').forEach((word) => {
-      if (word === '') {
-        return;
-      }
-
-      // https://regex101.com/r/pN9XfK/1
-      const matchNegative = word.match(/^-(prefix:|tag:)?(.+)$/);
-      // https://regex101.com/r/3qw9FQ/1
-      const matchPositive = word.match(/^(prefix:|tag:)?(.+)$/);
-
-      if (matchNegative != null) {
-        if (matchNegative[1] === 'prefix:') {
-          notPrefixPaths.push(matchNegative[2]);
-        }
-        else if (matchNegative[1] === 'tag:') {
-          notTags.push(matchNegative[2]);
-        }
-        else {
-          notMatchWords.push(matchNegative[2]);
-        }
-      }
-      else if (matchPositive != null) {
-        if (matchPositive[1] === 'prefix:') {
-          prefixPaths.push(matchPositive[2]);
-        }
-        else if (matchPositive[1] === 'tag:') {
-          tags.push(matchPositive[2]);
-        }
-        else {
-          matchWords.push(matchPositive[2]);
-        }
-      }
-    });
+    await this.appendFunctionScore(query, queryString);
+    this.appendHighlight(query);
 
-    return {
-      match: matchWords,
-      not_match: notMatchWords,
-      phrase: phraseWords,
-      not_phrase: notPhraseWords,
-      prefix: prefixPaths,
-      not_prefix: notPrefixPaths,
-      tag: tags,
-      not_tag: notTags,
-    };
+    return this.searchKeyword(query);
   }
 
   async syncPageUpdated(page, user) {
@@ -996,11 +934,11 @@ class ElasticsearchDelegator {
 
   // remove pages whitch should nod Indexed
   async syncPagesUpdated(pages, user) {
-    const shoudDeletePages = [];
+    const shoudDeletePages: any[] = [];
     pages.forEach((page) => {
       logger.debug('SearchClient.syncPageUpdated', page.path);
       if (!this.shouldIndexed(page)) {
-        shoudDeletePages.append(page);
+        shoudDeletePages.push(page);
       }
     });
 
@@ -1063,4 +1001,4 @@ class ElasticsearchDelegator {
 
 }
 
-module.exports = ElasticsearchDelegator;
+export default ElasticsearchDelegator;

+ 0 - 49
packages/app/src/server/service/search-delegator/searchbox.js

@@ -1,49 +0,0 @@
-import loggerFactory from '~/utils/logger';
-
-// eslint-disable-next-line no-unused-vars
-const logger = loggerFactory('growi:service:search-delegator:searchbox');
-
-const ElasticsearchDelegator = require('./elasticsearch');
-
-class SearchboxDelegator extends ElasticsearchDelegator {
-
-  /**
-   * @inheritdoc
-   */
-  getConnectionInfo() {
-    const searchboxSslUrl = this.configManager.getConfig('crowi', 'app:searchboxSslUrl');
-    const url = new URL(searchboxSslUrl);
-
-    const indexName = 'crowi';
-    const host = `${url.protocol}//${url.username}:${url.password}@${url.host}:443`;
-
-    return {
-      host,
-      httpAuth: '',
-      indexName,
-    };
-  }
-
-  /**
-   * @inheritdoc
-   */
-  async rebuildIndex() {
-    const { client, indexName, aliasName } = this;
-
-    // flush index
-    await client.indices.delete({
-      index: indexName,
-    });
-    await this.createIndex(indexName);
-    await this.addAllPages();
-
-    // put alias
-    await client.indices.putAlias({
-      name: aliasName,
-      index: indexName,
-    });
-  }
-
-}
-
-module.exports = SearchboxDelegator;

+ 0 - 158
packages/app/src/server/service/search.js

@@ -1,158 +0,0 @@
-import loggerFactory from '~/utils/logger';
-
-// eslint-disable-next-line no-unused-vars
-const logger = loggerFactory('growi:service:search');
-
-class SearchService {
-
-  constructor(crowi) {
-    this.crowi = crowi;
-    this.configManager = crowi.configManager;
-
-    this.isErrorOccuredOnHealthcheck = null;
-    this.isErrorOccuredOnSearching = null;
-
-    try {
-      this.delegator = this.generateDelegator();
-    }
-    catch (err) {
-      logger.error(err);
-    }
-
-    if (this.isConfigured) {
-      this.delegator.init();
-      this.registerUpdateEvent();
-    }
-  }
-
-  get isConfigured() {
-    return this.delegator != null;
-  }
-
-  get isReachable() {
-    return this.isConfigured && !this.isErrorOccuredOnHealthcheck && !this.isErrorOccuredOnSearching;
-  }
-
-  get isSearchboxEnabled() {
-    const uri = this.configManager.getConfig('crowi', 'app:searchboxSslUrl');
-    return uri != null && uri.length > 0;
-  }
-
-  get isElasticsearchEnabled() {
-    const uri = this.configManager.getConfig('crowi', 'app:elasticsearchUri');
-    return uri != null && uri.length > 0;
-  }
-
-  generateDelegator() {
-    logger.info('Initializing search delegator');
-
-    if (this.isSearchboxEnabled) {
-      const SearchboxDelegator = require('./search-delegator/searchbox');
-      logger.info('Searchbox is enabled');
-      return new SearchboxDelegator(this.configManager, this.crowi.socketIoService);
-    }
-    if (this.isElasticsearchEnabled) {
-      const ElasticsearchDelegator = require('./search-delegator/elasticsearch');
-      logger.info('Elasticsearch (not Searchbox) is enabled');
-      return new ElasticsearchDelegator(this.configManager, this.crowi.socketIoService);
-    }
-
-    logger.info('No elasticsearch URI is specified so that full text search is disabled.');
-  }
-
-  registerUpdateEvent() {
-    const pageEvent = this.crowi.event('page');
-    pageEvent.on('create', this.delegator.syncPageUpdated.bind(this.delegator));
-    pageEvent.on('update', this.delegator.syncPageUpdated.bind(this.delegator));
-    pageEvent.on('deleteCompletely', this.delegator.syncPagesDeletedCompletely.bind(this.delegator));
-    pageEvent.on('delete', this.delegator.syncPageDeleted.bind(this.delegator));
-    pageEvent.on('updateMany', this.delegator.syncPagesUpdated.bind(this.delegator));
-    pageEvent.on('syncDescendants', this.delegator.syncDescendantsPagesUpdated.bind(this.delegator));
-
-    const bookmarkEvent = this.crowi.event('bookmark');
-    bookmarkEvent.on('create', this.delegator.syncBookmarkChanged.bind(this.delegator));
-    bookmarkEvent.on('delete', this.delegator.syncBookmarkChanged.bind(this.delegator));
-
-    const commentEvent = this.crowi.event('comment');
-    commentEvent.on('create', this.delegator.syncCommentChanged.bind(this.delegator));
-    commentEvent.on('update', this.delegator.syncCommentChanged.bind(this.delegator));
-    commentEvent.on('delete', this.delegator.syncCommentChanged.bind(this.delegator));
-
-    const tagEvent = this.crowi.event('tag');
-    tagEvent.on('update', this.delegator.syncTagChanged.bind(this.delegator));
-  }
-
-  resetErrorStatus() {
-    this.isErrorOccuredOnHealthcheck = false;
-    this.isErrorOccuredOnSearching = false;
-  }
-
-  async reconnectClient() {
-    logger.info('Try to reconnect...');
-    this.delegator.initClient();
-
-    try {
-      await this.getInfoForHealth();
-
-      logger.info('Reconnecting succeeded.');
-      this.resetErrorStatus();
-    }
-    catch (err) {
-      throw err;
-    }
-  }
-
-  async getInfo() {
-    try {
-      return await this.delegator.getInfo();
-    }
-    catch (err) {
-      logger.error(err);
-      throw err;
-    }
-  }
-
-  async getInfoForHealth() {
-    try {
-      const result = await this.delegator.getInfoForHealth();
-
-      this.isErrorOccuredOnHealthcheck = false;
-      return result;
-    }
-    catch (err) {
-      logger.error(err);
-
-      // switch error flag, `isErrorOccuredOnHealthcheck` to be `false`
-      this.isErrorOccuredOnHealthcheck = true;
-      throw err;
-    }
-  }
-
-  async getInfoForAdmin() {
-    return this.delegator.getInfoForAdmin();
-  }
-
-  async normalizeIndices() {
-    return this.delegator.normalizeIndices();
-  }
-
-  async rebuildIndex() {
-    return this.delegator.rebuildIndex();
-  }
-
-  async searchKeyword(keyword, user, userGroups, searchOpts) {
-    try {
-      return await this.delegator.searchKeyword(keyword, user, userGroups, searchOpts);
-    }
-    catch (err) {
-      logger.error(err);
-
-      // switch error flag, `isReachable` to be `false`
-      this.isErrorOccuredOnSearching = true;
-      throw err;
-    }
-  }
-
-}
-
-module.exports = SearchService;

+ 421 - 0
packages/app/src/server/service/search.ts

@@ -0,0 +1,421 @@
+import RE2 from 're2';
+import xss from 'xss';
+
+import { SearchDelegatorName } from '~/interfaces/named-query';
+
+import NamedQuery from '../models/named-query';
+import {
+  SearchDelegator, SearchQueryParser, SearchResolver, ParsedQuery, Result, MetaData, SearchableData, QueryTerms,
+} from '../interfaces/search';
+import ElasticsearchDelegator from './search-delegator/elasticsearch';
+
+import loggerFactory from '~/utils/logger';
+import { serializeUserSecurely } from '../models/serializers/user-serializer';
+import { IPageHasId } from '~/interfaces/page';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:service:search');
+
+// options for filtering xss
+const filterXssOptions = {
+  whiteList: {
+    em: ['class'],
+  },
+};
+
+const filterXss = new xss.FilterXSS(filterXssOptions);
+
+const normalizeQueryString = (_queryString: string): string => {
+  let queryString = _queryString.trim();
+  queryString = queryString.replace(/\s+/g, ' ');
+
+  return queryString;
+};
+
+export type FormattedSearchResult = {
+  data: IPageHasId[]
+
+  totalCount: number
+
+  meta: {
+    total: number
+    took?: number
+    count?: number
+  }
+}
+
+class SearchService implements SearchQueryParser, SearchResolver {
+
+  crowi!: any
+
+  configManager!: any
+
+  isErrorOccuredOnHealthcheck: boolean | null
+
+  isErrorOccuredOnSearching: boolean | null
+
+  fullTextSearchDelegator: any & SearchDelegator
+
+  nqDelegators: {[key in SearchDelegatorName]: SearchDelegator}
+
+  constructor(crowi) {
+    this.crowi = crowi;
+    this.configManager = crowi.configManager;
+
+    this.isErrorOccuredOnHealthcheck = null;
+    this.isErrorOccuredOnSearching = null;
+
+    try {
+      this.fullTextSearchDelegator = this.generateFullTextSearchDelegator();
+      this.nqDelegators = this.generateNQDelegators(this.fullTextSearchDelegator);
+      logger.info('Succeeded to initialize search delegators');
+    }
+    catch (err) {
+      logger.error(err);
+    }
+
+    if (this.isConfigured) {
+      this.fullTextSearchDelegator.init();
+      this.registerUpdateEvent();
+    }
+  }
+
+  get isConfigured() {
+    return this.fullTextSearchDelegator != null;
+  }
+
+  get isReachable() {
+    return this.isConfigured && !this.isErrorOccuredOnHealthcheck && !this.isErrorOccuredOnSearching;
+  }
+
+  get isElasticsearchEnabled() {
+    const uri = this.configManager.getConfig('crowi', 'app:elasticsearchUri');
+    return uri != null && uri.length > 0;
+  }
+
+  generateFullTextSearchDelegator() {
+    logger.info('Initializing search delegator');
+
+    if (this.isElasticsearchEnabled) {
+      logger.info('Elasticsearch is enabled');
+      return new ElasticsearchDelegator(this.configManager, this.crowi.socketIoService);
+    }
+
+    logger.info('No elasticsearch URI is specified so that full text search is disabled.');
+  }
+
+  generateNQDelegators(defaultDelegator: SearchDelegator): {[key in SearchDelegatorName]: SearchDelegator} {
+    return {
+      [SearchDelegatorName.DEFAULT]: defaultDelegator,
+    };
+  }
+
+  registerUpdateEvent() {
+    const pageEvent = this.crowi.event('page');
+    pageEvent.on('create', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
+    pageEvent.on('update', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
+    pageEvent.on('deleteCompletely', this.fullTextSearchDelegator.syncPagesDeletedCompletely.bind(this.fullTextSearchDelegator));
+    pageEvent.on('delete', this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator));
+    pageEvent.on('updateMany', this.fullTextSearchDelegator.syncPagesUpdated.bind(this.fullTextSearchDelegator));
+    pageEvent.on('syncDescendants', this.fullTextSearchDelegator.syncDescendantsPagesUpdated.bind(this.fullTextSearchDelegator));
+    pageEvent.on('addSeenUsers', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
+
+    const bookmarkEvent = this.crowi.event('bookmark');
+    bookmarkEvent.on('create', this.fullTextSearchDelegator.syncBookmarkChanged.bind(this.fullTextSearchDelegator));
+    bookmarkEvent.on('delete', this.fullTextSearchDelegator.syncBookmarkChanged.bind(this.fullTextSearchDelegator));
+
+    const tagEvent = this.crowi.event('tag');
+    tagEvent.on('update', this.fullTextSearchDelegator.syncTagChanged.bind(this.fullTextSearchDelegator));
+
+    const commentEvent = this.crowi.event('comment');
+    commentEvent.on('create', this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
+    commentEvent.on('update', this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
+    commentEvent.on('delete', this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
+  }
+
+  resetErrorStatus() {
+    this.isErrorOccuredOnHealthcheck = false;
+    this.isErrorOccuredOnSearching = false;
+  }
+
+  async reconnectClient() {
+    logger.info('Try to reconnect...');
+    this.fullTextSearchDelegator.initClient();
+
+    try {
+      await this.getInfoForHealth();
+
+      logger.info('Reconnecting succeeded.');
+      this.resetErrorStatus();
+    }
+    catch (err) {
+      throw err;
+    }
+  }
+
+  async getInfo() {
+    try {
+      return await this.fullTextSearchDelegator.getInfo();
+    }
+    catch (err) {
+      logger.error(err);
+      throw err;
+    }
+  }
+
+  async getInfoForHealth() {
+    try {
+      const result = await this.fullTextSearchDelegator.getInfoForHealth();
+
+      this.isErrorOccuredOnHealthcheck = false;
+      return result;
+    }
+    catch (err) {
+      logger.error(err);
+
+      // switch error flag, `isErrorOccuredOnHealthcheck` to be `false`
+      this.isErrorOccuredOnHealthcheck = true;
+      throw err;
+    }
+  }
+
+  async getInfoForAdmin() {
+    return this.fullTextSearchDelegator.getInfoForAdmin();
+  }
+
+  async normalizeIndices() {
+    return this.fullTextSearchDelegator.normalizeIndices();
+  }
+
+  async rebuildIndex() {
+    return this.fullTextSearchDelegator.rebuildIndex();
+  }
+
+  async parseSearchQuery(_queryString: string): Promise<ParsedQuery> {
+    const regexp = new RE2(/^\[nq:.+\]$/g); // https://regex101.com/r/FzDUvT/1
+    const replaceRegexp = new RE2(/\[nq:|\]/g);
+
+    const queryString = normalizeQueryString(_queryString);
+
+    // when Normal Query
+    if (!regexp.test(queryString)) {
+      return { queryString, terms: this.parseQueryString(queryString) };
+    }
+
+    // when Named Query
+    const name = queryString.replace(replaceRegexp, '');
+    const nq = await NamedQuery.findOne({ name });
+
+    // will delegate to full-text search
+    if (nq == null) {
+      return { queryString, terms: this.parseQueryString(queryString) };
+    }
+
+    const { aliasOf, delegatorName } = nq;
+
+    let parsedQuery;
+    if (aliasOf != null) {
+      parsedQuery = { queryString: normalizeQueryString(aliasOf), terms: this.parseQueryString(aliasOf) };
+    }
+    if (delegatorName != null) {
+      parsedQuery = { queryString, delegatorName };
+    }
+
+    return parsedQuery;
+  }
+
+  async resolve(parsedQuery: ParsedQuery): Promise<[SearchDelegator, SearchableData | null]> {
+    const { queryString, terms, delegatorName } = parsedQuery;
+    if (delegatorName != null) {
+      const nqDelegator = this.nqDelegators[delegatorName];
+      if (nqDelegator != null) {
+        return [nqDelegator, null];
+      }
+    }
+
+    const data = {
+      queryString,
+      terms: terms as QueryTerms,
+    };
+    return [this.nqDelegators[SearchDelegatorName.DEFAULT], data];
+  }
+
+  async searchKeyword(keyword: string, user, userGroups, searchOpts): Promise<[Result<any> & MetaData, string]> {
+    let parsedQuery;
+    // parse
+    try {
+      parsedQuery = await this.parseSearchQuery(keyword);
+    }
+    catch (err) {
+      logger.error('Error occurred while parseSearchQuery', err);
+      throw err;
+    }
+
+    let delegator;
+    let data;
+    // resolve
+    try {
+      [delegator, data] = await this.resolve(parsedQuery);
+    }
+    catch (err) {
+      logger.error('Error occurred while resolving search delegator', err);
+      throw err;
+    }
+
+    return [await delegator.search(data, user, userGroups, searchOpts), delegator.name];
+  }
+
+  parseQueryString(queryString: string): QueryTerms {
+    // terms
+    const matchWords: string[] = [];
+    const notMatchWords: string[] = [];
+    const phraseWords: string[] = [];
+    const notPhraseWords: string[] = [];
+    const prefixPaths: string[] = [];
+    const notPrefixPaths: string[] = [];
+    const tags: string[] = [];
+    const notTags: string[] = [];
+
+    // First: Parse phrase keywords
+    const phraseRegExp = new RegExp(/(-?"[^"]+")/g);
+    const phrases = queryString.match(phraseRegExp);
+
+    if (phrases !== null) {
+      queryString = queryString.replace(phraseRegExp, ''); // eslint-disable-line no-param-reassign
+
+      phrases.forEach((phrase) => {
+        phrase.trim();
+        if (phrase.match(/^-/)) {
+          notPhraseWords.push(phrase.replace(/^-/, ''));
+        }
+        else {
+          phraseWords.push(phrase);
+        }
+      });
+    }
+
+    // Second: Parse other keywords (include minus keywords)
+    queryString.split(' ').forEach((word) => {
+      if (word === '') {
+        return;
+      }
+
+      // https://regex101.com/r/pN9XfK/1
+      const matchNegative = word.match(/^-(prefix:|tag:)?(.+)$/);
+      // https://regex101.com/r/3qw9FQ/1
+      const matchPositive = word.match(/^(prefix:|tag:)?(.+)$/);
+
+      if (matchNegative != null) {
+        if (matchNegative[1] === 'prefix:') {
+          notPrefixPaths.push(matchNegative[2]);
+        }
+        else if (matchNegative[1] === 'tag:') {
+          notTags.push(matchNegative[2]);
+        }
+        else {
+          notMatchWords.push(matchNegative[2]);
+        }
+      }
+      else if (matchPositive != null) {
+        if (matchPositive[1] === 'prefix:') {
+          prefixPaths.push(matchPositive[2]);
+        }
+        else if (matchPositive[1] === 'tag:') {
+          tags.push(matchPositive[2]);
+        }
+        else {
+          matchWords.push(matchPositive[2]);
+        }
+      }
+    });
+
+    const terms = {
+      match: matchWords,
+      not_match: notMatchWords,
+      phrase: phraseWords,
+      not_phrase: notPhraseWords,
+      prefix: prefixPaths,
+      not_prefix: notPrefixPaths,
+      tag: tags,
+      not_tag: notTags,
+    };
+
+    return terms;
+  }
+
+  // TODO: optimize the way to check isFormattable e.g. check data schema of searchResult
+  // So far, it determines by delegatorName passed by searchService.searchKeyword
+  checkIsFormattable(searchResult, delegatorName: SearchDelegatorName): boolean {
+    return delegatorName === SearchDelegatorName.DEFAULT;
+  }
+
+  /**
+   * formatting result
+   */
+  async formatSearchResult(searchResult: Result<any> & MetaData, delegatorName: SearchDelegatorName): Promise<FormattedSearchResult> {
+    if (!this.checkIsFormattable(searchResult, delegatorName)) {
+      return {
+        data: searchResult.data,
+        totalCount: searchResult.data.length,
+        meta: searchResult.meta,
+      };
+    }
+
+    /*
+     * Format ElasticSearch result
+     */
+    const Page = this.crowi.model('Page') as any; // TODO: typescriptize model
+    const User = this.crowi.model('User');
+    const result = {} as FormattedSearchResult;
+
+    // get page data
+    const pageIds = searchResult.data.map((page) => { return page._id });
+    const findPageResult = await Page.findListByPageIds(pageIds);
+
+    // set meta data
+    result.meta = searchResult.meta;
+    result.totalCount = findPageResult.totalCount;
+
+    // set search result page data
+    result.data = searchResult.data.map((data) => {
+      const pageData = findPageResult.pages.find((pageData) => {
+        return pageData.id === data._id;
+      });
+
+      // add tags and seenUserCount to pageData
+      pageData._doc.tags = data._source.tag_names;
+      pageData._doc.seenUserCount = (pageData.seenUsers && pageData.seenUsers.length) || 0;
+
+      // serialize lastUpdateUser
+      if (pageData.lastUpdateUser != null && pageData.lastUpdateUser instanceof User) {
+        pageData.lastUpdateUser = serializeUserSecurely(pageData.lastUpdateUser);
+      }
+
+      // increment elasticSearchResult
+      let elasticSearchResult;
+      const highlightData = data._highlight;
+      if (highlightData != null) {
+        const snippet = highlightData['body.en'] || highlightData['body.ja'] || '';
+        const pathMatch = highlightData['path.en'] || highlightData['path.ja'] || '';
+
+        elasticSearchResult = {
+          snippet: filterXss.process(snippet),
+          highlightedPath: filterXss.process(pathMatch),
+        };
+      }
+
+      // generate pageMeta data
+      const pageMeta = {
+        bookmarkCount: data._source.bookmark_count || 0,
+        elasticSearchResult,
+      };
+
+      return pageData; // { pageData, pageMeta } at dev/5.0.x
+    });
+
+    return result;
+  }
+
+}
+
+export default SearchService;

+ 1 - 1
packages/app/src/server/service/slack-command-handler/create-page-service.js

@@ -3,7 +3,7 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:service:CreatePageService');
 const { reshapeContentsBody, respond, markdownSectionBlock } = require('@growi/slack');
 const mongoose = require('mongoose');
-const pathUtils = require('growi-commons').pathUtils;
+const { pathUtils } = require('@growi/core');
 
 class CreatePageService {
 

+ 1 - 1
packages/app/src/server/service/slack-command-handler/search.js

@@ -41,7 +41,7 @@ module.exports = (crowi) => {
 
     const { searchService } = crowi;
     const options = { limit: PAGINGLIMIT, offset };
-    const results = await searchService.searchKeyword(keywords, null, {}, options);
+    const [results] = await searchService.searchKeyword(keywords, null, {}, options);
     const resultsTotal = results.meta.total;
 
     const pages = results.data.map((data) => {

+ 1 - 1
packages/app/src/server/util/middlewares.js

@@ -5,7 +5,7 @@ import loggerFactory from '~/utils/logger';
 // eslint-disable-next-line no-unused-vars
 
 const { formatDistanceStrict } = require('date-fns');
-const pathUtils = require('growi-commons').pathUtils;
+const { pathUtils } = require('@growi/core');
 const md5 = require('md5');
 const entities = require('entities');
 

+ 1 - 1
packages/app/src/server/util/swigFunctions.js

@@ -2,7 +2,7 @@ module.exports = function(crowi, req, locals) {
   const debug = require('debug')('growi:lib:swigFunctions');
   const stringWidth = require('string-width');
 
-  const { pathUtils } = require('growi-commons');
+  const { pathUtils } = require('@growi/core');
 
   const Page = crowi.model('Page');
   const User = crowi.model('User');

+ 6 - 0
packages/app/src/server/views/layout/layout.html

@@ -120,6 +120,12 @@
   {{ user|json|safe|preventXss }}
   </script>
 {% endif %}
+{% if userUISettings != null %}
+  <script type="application/json" id="growi-user-ui-settings">
+  {{ userUISettings|json|safe }}
+  </script>
+{% endif %}
+
 
 {% block custom_script %}
 <script>

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

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

+ 1 - 1
packages/app/src/services/cdn-resources-service.js

@@ -4,7 +4,7 @@ import { resolveFromRoot } from '~/utils/project-dir-utils';
 const { URL } = require('url');
 const urljoin = require('url-join');
 
-const { envUtils } = require('growi-commons');
+const { envUtils } = require('@growi/core');
 
 const cdnLocalScriptRoot = 'public/static/js/cdn';
 const cdnLocalScriptWebRoot = '/static/js/cdn';

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

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

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

+ 15 - 69
packages/app/src/stores/ui.tsx

@@ -1,17 +1,14 @@
 import {
-  useSWRConfig, SWRResponse, Key, Fetcher, Middleware,
+  useSWRConfig, SWRResponse, Key, Fetcher,
 } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { Breakpoint, addBreakpointListener } from '@growi/ui';
 
-import { apiv3Get } from '~/client/util/apiv3-client';
 import { SidebarContentsType } from '~/interfaces/ui';
 import loggerFactory from '~/utils/logger';
 
-import { sessionStorageMiddleware } from './middlewares/sync-to-storage';
 import { useStaticSWR } from './use-static-swr';
-import { IUserUISettings } from '~/interfaces/user-ui-settings';
 import { useIsEditable } from './context';
 
 const logger = loggerFactory('growi:stores:ui');
@@ -36,16 +33,6 @@ export type EditorMode = typeof EditorMode[keyof typeof EditorMode];
  *                      for switching UI
  *********************************************************** */
 
-export const useSWRxUserUISettings = (): SWRResponse<IUserUISettings, Error> => {
-  const key = isServer ? null : 'userUISettings';
-
-  return useSWRImmutable(
-    key,
-    () => apiv3Get<IUserUISettings>('/user-ui-settings').then(response => response.data),
-  );
-};
-
-
 export const useIsMobile = (): SWRResponse<boolean|null, Error> => {
   const key = isServer ? null : 'isMobile';
 
@@ -167,20 +154,24 @@ export const useIsDeviceSmallerThanMd = (): SWRResponse<boolean|null, Error> =>
   return useStaticSWR(key);
 };
 
-export const usePreferDrawerModeByUser = (isPrefered?: boolean): SWRResponse<boolean, Error> => {
-  const { data } = useSWRxUserUISettings();
-  const key: Key = data === undefined ? null : 'preferDrawerModeByUser';
-  const initialData = data?.preferDrawerModeByUser;
+export const usePreferDrawerModeByUser = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR('preferDrawerModeByUser', initialData ?? null, { fallbackData: false });
+};
+
+export const usePreferDrawerModeOnEditByUser = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR('preferDrawerModeOnEditByUser', initialData ?? null, { fallbackData: true });
+};
 
-  return useStaticSWR(key, isPrefered || null, { fallbackData: initialData, use: [sessionStorageMiddleware] });
+export const useSidebarCollapsed = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR('isSidebarCollapsed', initialData ?? null, { fallbackData: false });
 };
 
-export const usePreferDrawerModeOnEditByUser = (isPrefered?: boolean): SWRResponse<boolean, Error> => {
-  const { data } = useSWRxUserUISettings();
-  const key: Key = data === undefined ? null : 'preferDrawerModeOnEditByUser';
-  const initialData = data?.preferDrawerModeOnEditByUser;
+export const useCurrentSidebarContents = (initialData?: SidebarContentsType): SWRResponse<SidebarContentsType, Error> => {
+  return useStaticSWR('sidebarContents', initialData ?? null, { fallbackData: SidebarContentsType.RECENT });
+};
 
-  return useStaticSWR(key, isPrefered || null, { fallbackData: initialData, use: [sessionStorageMiddleware] });
+export const useCurrentProductNavWidth = (initialData?: number): SWRResponse<number, Error> => {
+  return useStaticSWR('productNavWidth', initialData ?? null, { fallbackData: 320 });
 };
 
 export const useDrawerMode = (): SWRResponse<boolean, Error> => {
@@ -215,51 +206,6 @@ export const useDrawerOpened = (isOpened?: boolean): SWRResponse<boolean, Error>
   return useStaticSWR('isDrawerOpened', isOpened || null, { fallbackData: initialData });
 };
 
-export const useSidebarCollapsed = (): SWRResponse<boolean, Error> => {
-  const { data } = useSWRxUserUISettings();
-  const key = data === undefined ? null : 'isSidebarCollapsed';
-  const initialData = data?.isSidebarCollapsed || false;
-
-  return useStaticSWR(
-    key,
-    null,
-    {
-      fallbackData: initialData,
-      use: [sessionStorageMiddleware],
-    },
-  );
-};
-
-export const useCurrentSidebarContents = (): SWRResponse<SidebarContentsType, Error> => {
-  const { data } = useSWRxUserUISettings();
-  const key = data === undefined ? null : 'sidebarContents';
-  const initialData = data?.currentSidebarContents || SidebarContentsType.RECENT;
-
-  return useStaticSWR(
-    key,
-    null,
-    {
-      fallbackData: initialData,
-      use: [sessionStorageMiddleware],
-    },
-  );
-};
-
-export const useCurrentProductNavWidth = (): SWRResponse<number, Error> => {
-  const { data } = useSWRxUserUISettings();
-  const key = data === undefined ? null : 'productNavWidth';
-  const initialData = data?.currentProductNavWidth || 320;
-
-  return useStaticSWR(
-    key,
-    null,
-    {
-      fallbackData: initialData,
-      use: [sessionStorageMiddleware],
-    },
-  );
-};
-
 export const useSidebarResizeDisabled = (isDisabled?: boolean): SWRResponse<boolean, Error> => {
   const initialData = false;
   return useStaticSWR('isSidebarResizeDisabled', isDisabled || null, { fallbackData: initialData });

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

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

+ 3 - 17
packages/app/src/styles/_sidebar.scss

@@ -22,6 +22,8 @@
   position: sticky;
   top: $grw-navbar-border-width;
 
+  height: 100vh;
+
   .grw-navigation-resize-button {
     position: fixed;
 
@@ -260,7 +262,7 @@
     top: 0;
     width: 0;
   }
-  div.navigation {
+  div.navigation.transition-enabled {
     max-width: 80vw;
 
     // apply transition
@@ -322,22 +324,6 @@
   }
 }
 
-// supress transition
-.grw-sidebar {
-  &.grw-sidebar-supress-transitions-to-drawer {
-    div.navigation {
-      transition: none !important;
-    }
-  }
-
-  &.grw-sidebar-supress-transitions-to-dock {
-    div.content,
-    div.contextual-navigation {
-      transition: none !important;
-    }
-  }
-}
-
 .grw-sidebar-backdrop.modal-backdrop {
   z-index: $zindex-fixed + 1;
 }

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

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

+ 0 - 36
packages/app/src/test/integration/service/search-delegator/searchbox.test.js

@@ -1,36 +0,0 @@
-const SearchboxDelegator = require('~/server/service/search-delegator/searchbox');
-
-describe('SearchboxDelegator test', () => {
-
-  let delegator;
-
-  describe('getConnectionInfo()', () => {
-
-    let configManagerMock;
-    let searchEventMock;
-
-    beforeEach(() => {
-      configManagerMock = {};
-      searchEventMock = {};
-
-      // setup mock
-      configManagerMock.getConfig = jest.fn()
-        .mockReturnValue('https://paas:7e530aafad58c892a8778827ae80c879@thorin-us-east-1.searchly.com');
-
-      delegator = new SearchboxDelegator(configManagerMock, searchEventMock);
-    });
-
-    test('returns expected object', async() => {
-
-      const { host, httpAuth, indexName } = delegator.getConnectionInfo();
-
-      expect(configManagerMock.getConfig).toHaveBeenCalledWith('crowi', 'app:searchboxSslUrl');
-      expect(host).toBe('https://paas:7e530aafad58c892a8778827ae80c879@thorin-us-east-1.searchly.com:443');
-      expect(httpAuth).toBe('');
-      expect(indexName).toBe('crowi');
-    });
-
-  });
-
-
-});

+ 114 - 0
packages/app/src/test/integration/service/search/search-service.test.js

@@ -0,0 +1,114 @@
+import mongoose from 'mongoose';
+
+import SearchService from '~/server/service/search';
+import NamedQuery from '~/server/models/named-query';
+
+const { getInstance } = require('../../setup-crowi');
+
+describe('SearchService test', () => {
+  let crowi;
+  let searchService;
+
+  const DEFAULT = 'FullTextSearch';
+
+  // let NamedQuery;
+
+  let dummyAliasOf;
+
+  let namedQuery1;
+  let namedQuery2;
+
+  const dummyFullTextSearchDelegator = {
+    search() {
+      return;
+    },
+  };
+
+  beforeAll(async() => {
+    crowi = await getInstance();
+    searchService = new SearchService(crowi);
+    searchService.nqDelegators = {
+      ...searchService.nqDelegators,
+      [DEFAULT]: dummyFullTextSearchDelegator, // override with dummy full-text search delegator
+    };
+
+    dummyAliasOf = 'match -notmatch "phrase" -"notphrase" prefix:/pre1 -prefix:/pre2 tag:Tag1 -tag:Tag2';
+
+    await NamedQuery.insertMany([
+      { name: 'named_query2', aliasOf: dummyAliasOf },
+    ]);
+
+    namedQuery1 = await NamedQuery.findOne({ name: 'named_query1' });
+    namedQuery2 = await NamedQuery.findOne({ name: 'named_query2' });
+  });
+
+
+  describe('parseQueryString()', () => {
+    test('should parse queryString', async() => {
+      const queryString = 'match -notmatch "phrase" -"notphrase" prefix:/pre1 -prefix:/pre2 tag:Tag1 -tag:Tag2';
+      const terms = await searchService.parseQueryString(queryString);
+
+      const expected = { // QueryTerms
+        match: ['match'],
+        not_match: ['notmatch'],
+        phrase: ['"phrase"'],
+        not_phrase: ['"notphrase"'],
+        prefix: ['/pre1'],
+        not_prefix: ['/pre2'],
+        tag: ['Tag1'],
+        not_tag: ['Tag2'],
+      };
+
+      expect(terms).toStrictEqual(expected);
+    });
+  });
+
+  describe('parseSearchQuery()', () => {
+
+    test('should return result with expanded aliasOf value', async() => {
+      const queryString = '[nq:named_query2]';
+      const parsedQuery = await searchService.parseSearchQuery(queryString);
+      const expected = {
+        queryString: dummyAliasOf,
+        terms: {
+          match: ['match'],
+          not_match: ['notmatch'],
+          phrase: ['"phrase"'],
+          not_phrase: ['"notphrase"'],
+          prefix: ['/pre1'],
+          not_prefix: ['/pre2'],
+          tag: ['Tag1'],
+          not_tag: ['Tag2'],
+        },
+      };
+
+      expect(parsedQuery).toStrictEqual(expected);
+    });
+  });
+
+  describe('resolve()', () => {
+    test('should resolve as full-text search delegator', async() => {
+      const parsedQuery = {
+        queryString: dummyAliasOf,
+        terms: {
+          match: ['match'],
+          not_match: ['notmatch'],
+          phrase: ['"phrase"'],
+          not_phrase: ['"notphrase"'],
+          prefix: ['/pre1'],
+          not_prefix: ['/pre2'],
+          tag: ['Tag1'],
+          not_tag: ['Tag2'],
+        },
+      };
+
+      const [delegator, data] = await searchService.resolve(parsedQuery);
+
+      const expectedData = parsedQuery;
+
+      expect(data).toStrictEqual(expectedData);
+      expect(typeof delegator.search).toBe('function');
+    });
+  });
+
+});

+ 1 - 1
packages/codemirror-textlint/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/codemirror-textlint",
-  "version": "4.5.3-RC.0",
+  "version": "4.5.4-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "scripts": {

+ 0 - 27
packages/core/README.md

@@ -1,27 +0,0 @@
-# growi-commons
-
-[![dependencies status](https://david-dm.org/weseek/growi-commons.svg)](https://david-dm.org/weseek/growi-commons)
-[![devDependencies Status](https://david-dm.org/weseek/growi-commons/dev-status.svg)](https://david-dm.org/weseek/growi-commons?type=dev)
-[![MIT License](http://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE)
-
-[GROWI](https://growi.org) Commons Libraries to develop GROWI and plugins
-
-
-Overview
---------
-
-growi-commons package is includes some functions, classes and modules to develop GROWI substance and GROWI plugins.
-
-Install
---------
-
-1. install plugin
-
-    ```
-    $ npm install --save growi-commons
-    ```
-
-Documentation
-------------
-
-See https://docs.growi.org/api/commons/

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "4.5.3-RC.0",
+  "version": "4.5.4-RC.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

+ 11 - 14
packages/core/src/index.js

@@ -1,22 +1,19 @@
 import * as _pathUtils from './utils/path-utils';
+import * as _envUtils from './utils/env-utils';
 import * as _pagePathUtils from './utils/page-path-utils';
 import * as _templateChecker from './utils/template-checker';
-
-// module.exports = {
-//   BasicInterceptor: require('./utils/basic-interceptor'),
-//   envUtils: require('./utils/env-utils'),
-//   // plugin
-//   customTagUtils: require('./plugin/util/custom-tag-utils'),
-//   TagCacheManager: require('./plugin/service/tag-cache-manager'),
-//   // service
-//   LocalStorageManager: require('./service/localstorage-manager'),
-// };
-
-export * from './plugin/interfaces/plugin-definition-v4';
-export * from './models/devided-page-path';
-export * from './utils/mongoose-utils';
+import * as _customTagUtils from './plugin/util/custom-tag-utils';
 
 // export utils
 export const pathUtils = _pathUtils;
+export const envUtils = _envUtils;
 export const pagePathUtils = _pagePathUtils;
 export const templateChecker = _templateChecker;
+export const customTagUtils = _customTagUtils;
+
+export * from './plugin/interfaces/plugin-definition-v4';
+export * from './plugin/service/tag-cache-manager';
+export * from './models/devided-page-path';
+export * from './service/localstorage-manager';
+export * from './utils/basic-interceptor';
+export * from './utils/mongoose-utils';

+ 2 - 4
packages/core/src/plugin/service/tag-cache-manager.js

@@ -1,9 +1,9 @@
-const LocalStorageManager = require('../../service/localstorage-manager');
+import { LocalStorageManager } from '../../service/localstorage-manager';
 
 /**
  * Service Class for caching React state and TagContext
  */
-class TagCacheManager {
+export class TagCacheManager {
 
   /**
    * @callback generateCacheKey
@@ -67,5 +67,3 @@ class TagCacheManager {
   }
 
 }
-
-module.exports = TagCacheManager;

+ 1 - 3
packages/core/src/service/localstorage-manager.js

@@ -1,5 +1,5 @@
 let _instance = null;
-class LocalStorageManager {
+export class LocalStorageManager {
 
   static getInstance() {
     if (_instance == null) {
@@ -52,5 +52,3 @@ class LocalStorageManager {
   }
 
 }
-
-module.exports = LocalStorageManager;

+ 1 - 3
packages/core/src/utils/basic-interceptor.js

@@ -1,7 +1,7 @@
 /**
  * Basic Interceptor class
  */
-class BasicInterceptor {
+export class BasicInterceptor {
 
   /**
    * getter for id
@@ -43,5 +43,3 @@ class BasicInterceptor {
   }
 
 }
-
-module.exports = BasicInterceptor;

+ 1 - 1
packages/plugin-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-attachment-refs",
-  "version": "4.5.3-RC.0",
+  "version": "4.5.4-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPostRenderInterceptor.js

@@ -1,7 +1,7 @@
 import React from 'react';
 import ReactDOM from 'react-dom';
 
-import { BasicInterceptor } from 'growi-commons';
+import { BasicInterceptor } from '@growi/core';
 
 import RefsContext from '../RefsContext';
 import GalleryContext from '../GalleryContext';

+ 1 - 1
packages/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPreRenderInterceptor.js

@@ -1,4 +1,4 @@
-import { customTagUtils, BasicInterceptor } from 'growi-commons';
+import { customTagUtils, BasicInterceptor } from '@growi/core';
 
 import TagCacheManagerFactory from '../TagCacheManagerFactory';
 

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