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

Merge pull request #4816 from weseek/master

Release v4.5.3
Yuki Takei 4 лет назад
Родитель
Сommit
88a97562b1
100 измененных файлов с 2662 добавлено и 1965 удалено
  1. 1 1
      lerna.json
  2. 1 1
      package.json
  3. 0 2
      packages/app/config/webpack.dev.dll.js
  4. 12 12
      packages/app/package.json
  5. 10 0
      packages/app/resource/locales/en_US/notifications/userActivation.txt
  6. 11 3
      packages/app/resource/locales/en_US/translation.json
  7. 11 0
      packages/app/resource/locales/ja_JP/notifications/userActivation.txt
  8. 10 2
      packages/app/resource/locales/ja_JP/translation.json
  9. 10 0
      packages/app/resource/locales/zh_CN/notifications/userActivation.txt
  10. 11 3
      packages/app/resource/locales/zh_CN/translation.json
  11. 0 4
      packages/app/src/client/admin.jsx
  12. 3 9
      packages/app/src/client/app.jsx
  13. 32 80
      packages/app/src/client/legacy/crowi.js
  14. 25 0
      packages/app/src/client/nologin.jsx
  15. 12 1
      packages/app/src/client/services/AdminLocalSecurityContainer.js
  16. 40 8
      packages/app/src/client/services/ContextExtractor.tsx
  17. 3 5
      packages/app/src/client/services/EditorContainer.js
  18. 0 239
      packages/app/src/client/services/NavigationContainer.js
  19. 5 21
      packages/app/src/client/services/PageContainer.js
  20. 28 0
      packages/app/src/client/services/user-ui-settings.ts
  21. 27 0
      packages/app/src/client/util/blink-section-header.ts
  22. 15 0
      packages/app/src/client/util/editor.ts
  23. 45 0
      packages/app/src/client/util/smooth-scroll.ts
  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. 6 7
      packages/app/src/components/ContentLinkButtons.jsx
  28. 40 0
      packages/app/src/components/EventListeneres/HashChanged.tsx
  29. 9 6
      packages/app/src/components/Fab.jsx
  30. 9 11
      packages/app/src/components/Hotkeys/Subscribers/CreatePage.jsx
  31. 13 10
      packages/app/src/components/Hotkeys/Subscribers/EditPage.jsx
  32. 3 3
      packages/app/src/components/Icons/GrowiLogo.jsx
  33. 59 27
      packages/app/src/components/LoginForm.jsx
  34. 0 46
      packages/app/src/components/Navbar/DrawerToggler.jsx
  35. 28 0
      packages/app/src/components/Navbar/DrawerToggler.tsx
  36. 1 3
      packages/app/src/components/Navbar/GlobalSearch.jsx
  37. 0 115
      packages/app/src/components/Navbar/GrowiNavbar.jsx
  38. 128 0
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  39. 7 12
      packages/app/src/components/Navbar/GrowiNavbarBottom.jsx
  40. 13 12
      packages/app/src/components/Navbar/GrowiSubNavigation.jsx
  41. 17 15
      packages/app/src/components/Navbar/PageEditorModeManager.jsx
  42. 21 21
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  43. 5 7
      packages/app/src/components/Navbar/SubNavButtons.jsx
  44. 42 7
      packages/app/src/components/Page.jsx
  45. 30 17
      packages/app/src/components/Page/DisplaySwitcher.jsx
  46. 13 13
      packages/app/src/components/Page/NotFoundAlert.jsx
  47. 21 8
      packages/app/src/components/Page/RevisionRenderer.jsx
  48. 4 3
      packages/app/src/components/Page/TagLabels.jsx
  49. 1 1
      packages/app/src/components/PageComment/CommentEditor.jsx
  50. 1 0
      packages/app/src/components/PageContentFooter.jsx
  51. 8 7
      packages/app/src/components/PageCreateModal.jsx
  52. 47 5
      packages/app/src/components/PageEditor.jsx
  53. 5 2
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  54. 36 30
      packages/app/src/components/PageEditor/EditorNavbarBottom.tsx
  55. 2 1
      packages/app/src/components/PageEditor/LinkEditModal.jsx
  56. 37 4
      packages/app/src/components/PageEditorByHackmd.jsx
  57. 49 6
      packages/app/src/components/SavePageControls.jsx
  58. 0 242
      packages/app/src/components/Sidebar.jsx
  59. 282 0
      packages/app/src/components/Sidebar.tsx
  60. 17 39
      packages/app/src/components/Sidebar/CustomSidebar.tsx
  61. 20 0
      packages/app/src/components/Sidebar/NavigationResizeHexagon.tsx
  62. 6 100
      packages/app/src/components/Sidebar/RecentChanges.tsx
  63. 0 49
      packages/app/src/components/Sidebar/SidebarContents.jsx
  64. 31 0
      packages/app/src/components/Sidebar/SidebarContents.tsx
  65. 0 94
      packages/app/src/components/Sidebar/SidebarNav.jsx
  66. 96 0
      packages/app/src/components/Sidebar/SidebarNav.tsx
  67. 0 87
      packages/app/src/components/SlackNotification.jsx
  68. 67 0
      packages/app/src/components/SlackNotification.tsx
  69. 9 16
      packages/app/src/components/StickyStretchableScroller.jsx
  70. 6 6
      packages/app/src/components/TableOfContents.jsx
  71. 7 0
      packages/app/src/interfaces/common.ts
  72. 3 0
      packages/app/src/interfaces/has-object-id.ts
  73. 14 0
      packages/app/src/interfaces/named-query.ts
  74. 26 4
      packages/app/src/interfaces/page.ts
  75. 31 0
      packages/app/src/interfaces/search.ts
  76. 6 0
      packages/app/src/interfaces/ui.ts
  77. 12 0
      packages/app/src/interfaces/user-ui-settings.ts
  78. 1 1
      packages/app/src/server/crowi/index.js
  79. 45 0
      packages/app/src/server/interfaces/search.ts
  80. 22 0
      packages/app/src/server/middlewares/inject-user-registration-order-by-token-middleware.ts
  81. 35 0
      packages/app/src/server/middlewares/inject-user-ui-settings-to-localvars.ts
  82. 36 0
      packages/app/src/server/models/named-query.ts
  83. 67 0
      packages/app/src/server/models/user-registration-order.ts
  84. 28 0
      packages/app/src/server/models/user-ui-settings.ts
  85. 10 0
      packages/app/src/server/models/user.js
  86. 14 0
      packages/app/src/server/routes/apiv3/index.js
  87. 69 1
      packages/app/src/server/routes/apiv3/page.js
  88. 3 0
      packages/app/src/server/routes/apiv3/security-setting.js
  89. 138 0
      packages/app/src/server/routes/apiv3/user-activation.ts
  90. 68 0
      packages/app/src/server/routes/apiv3/user-ui-settings.ts
  91. 25 18
      packages/app/src/server/routes/index.js
  92. 0 83
      packages/app/src/server/routes/page.js
  93. 20 37
      packages/app/src/server/routes/search.js
  94. 114 0
      packages/app/src/server/routes/user-activation.ts
  95. 9 6
      packages/app/src/server/routes/user.js
  96. 6 0
      packages/app/src/server/service/config-loader.ts
  97. 38 1
      packages/app/src/server/service/page.js
  98. 119 181
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  99. 0 49
      packages/app/src/server/service/search-delegator/searchbox.js
  100. 0 158
      packages/app/src/server/service/search.js

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

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

@@ -10,8 +10,6 @@ module.exports = {
   entry: {
     dlls: [
       // Libraries
-      '@atlaskit/drawer',
-      '@atlaskit/navigation-next',
       'axios',
       'browser-bunyan', 'bunyan-format',
       'codemirror', 'react-codemirror2',

+ 12 - 12
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "4.5.2",
+  "version": "4.5.3-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.2",
-    "@growi/plugin-attachment-refs": "^4.5.2",
-    "@growi/plugin-lsx": "^4.5.2",
-    "@growi/plugin-pukiwiki-like-linker": "^4.5.2",
-    "@growi/slack": "^4.5.2",
+    "@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",
     "@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",
@@ -131,6 +131,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",
@@ -156,17 +157,16 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.0.16",
-    "@atlaskit/drawer": "^5.3.7",
-    "@atlaskit/navigation-next": "^8.0.5",
-    "@growi/ui": "^4.5.2",
+    "@growi/ui": "^4.5.3-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
+    "@types/jquery": "^3.5.8",
     "@types/multer": "^1.4.5",
     "@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",
@@ -227,7 +227,7 @@
     "sass-loader": "^10.1.1",
     "simple-load-script": "^1.0.2",
     "socket.io-client": "^4.2.0",
-    "sticky-events": "^3.1.3",
+    "sticky-events": "^3.4.11",
     "style-loader": "^1.0.0",
     "styled-components": "^5.0.1",
     "stylelint": "^14.0.1",

+ 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网格",

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

@@ -25,8 +25,6 @@ import ExportArchiveDataPage from '../components/Admin/ExportArchiveDataPage';
 import FullTextSearchManagement from '../components/Admin/FullTextSearchManagement';
 import AdminNavigation from '../components/Admin/Common/AdminNavigation';
 
-import NavigationContainer from '~/client/services/NavigationContainer';
-
 import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
@@ -57,7 +55,6 @@ appContainer.initContents();
 const { i18n } = appContainer;
 
 // create unstated container instance
-const navigationContainer = new NavigationContainer(appContainer);
 const adminAppContainer = new AdminAppContainer(appContainer);
 const adminImportContainer = new AdminImportContainer(appContainer);
 const adminSocketIoContainer = new AdminSocketIoContainer(appContainer);
@@ -71,7 +68,6 @@ const adminMarkDownContainer = new AdminMarkDownContainer(appContainer);
 const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer(appContainer);
 const injectableContainers = [
   appContainer,
-  navigationContainer,
   adminAppContainer,
   adminImportContainer,
   adminSocketIoContainer,

+ 3 - 9
packages/app/src/client/app.jsx

@@ -41,7 +41,7 @@ import PersonalSettings from '../components/Me/PersonalSettings';
 import GrowiSubNavigation from '../components/Navbar/GrowiSubNavigation';
 import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationSwitcher';
 
-import NavigationContainer from '~/client/services/NavigationContainer';
+import ContextExtractor from '~/client/services/ContextExtractor';
 import PageContainer from '~/client/services/PageContainer';
 import PageHistoryContainer from '~/client/services/PageHistoryContainer';
 import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
@@ -50,7 +50,6 @@ import EditorContainer from '~/client/services/EditorContainer';
 import TagContainer from '~/client/services/TagContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
 import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
-import ContextExtractor from '~/client/services/ContextExtractor';
 
 import { appContainer, componentMappings } from './base';
 
@@ -62,7 +61,6 @@ const { i18n } = appContainer;
 const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 
 // create unstated container instance
-const navigationContainer = new NavigationContainer(appContainer);
 const pageContainer = new PageContainer(appContainer);
 const pageHistoryContainer = new PageHistoryContainer(appContainer, pageContainer);
 const revisionComparerContainer = new RevisionComparerContainer(appContainer, pageContainer);
@@ -72,7 +70,7 @@ const tagContainer = new TagContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
 const pageAccessoriesContainer = new PageAccessoriesContainer(appContainer);
 const injectableContainers = [
-  appContainer, socketIoContainer, navigationContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
+  appContainer, socketIoContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
   commentContainer, editorContainer, tagContainer, personalContainer, pageAccessoriesContainer,
 ];
 
@@ -99,7 +97,6 @@ Object.assign(componentMappings, {
 
   'not-found-page': <NotFoundPage />,
   'not-found-alert': <NotFoundAlert
-    onPageCreateClicked={navigationContainer.setEditorMode}
     isGuestUserMode={appContainer.isGuestUser}
     isHidden={pageContainer.state.isNotCreatable || pageContainer.state.isTrashPage}
   />,
@@ -118,8 +115,6 @@ Object.assign(componentMappings, {
   'duplicated-alert': <DuplicatedAlert />,
   'redirected-alert': <RedirectedAlert />,
   'renamed-alert': <RenamedAlert />,
-
-  'growi-context-extractor': <ContextExtractor />, // use static swr
 });
 
 // additional definitions if data exists
@@ -181,7 +176,7 @@ const elem = document.getElementById('growi-context-extractor');
 if (elem != null) {
   ReactDOM.render(
     <SWRConfig value={swrGlobalConfiguration}>
-      {componentMappings['growi-context-extractor']}
+      <ContextExtractor></ContextExtractor>
     </SWRConfig>,
     elem,
     renderMainComponents,
@@ -191,6 +186,5 @@ else {
   renderMainComponents();
 }
 
-
 // initialize scrollpos-styler
 ScrollPosStyler.init();

+ 32 - 80
packages/app/src/client/legacy/crowi.js

@@ -1,3 +1,5 @@
+const { blinkElem, blinkSectionHeaderAtBoot } = require('../util/blink-section-header');
+
 /* eslint-disable react/jsx-filename-extension */
 require('jquery.cookie');
 
@@ -16,8 +18,6 @@ window.Crowi = Crowi;
  */
 Crowi.setCaretLineData = function(line) {
   const { appContainer } = window;
-  const navigationContainer = appContainer.getContainer('NavigationContainer');
-  navigationContainer.setEditorMode('edit');
   const pageEditorDom = document.querySelector('#page-editor');
   pageEditorDom.setAttribute('data-caret-line', line);
 };
@@ -112,74 +112,32 @@ Crowi.initClassesByOS = function() {
   });
 };
 
-Crowi.findHashFromUrl = function(url) {
-  let match;
-  /* eslint-disable no-cond-assign */
-  if (match = url.match(/#(.+)$/)) {
-    return `#${match[1]}`;
-  }
-  /* eslint-enable no-cond-assign */
-
-  return '';
-};
-
-Crowi.findSectionHeader = function(hash) {
-  if (hash.length === 0) {
-    return;
-  }
-
-  // omit '#'
-  const id = hash.replace('#', '');
-  // don't use jQuery and document.querySelector
-  //  because hash may containe Base64 encoded strings
-  const elem = document.getElementById(id);
-  if (elem != null && elem.tagName.match(/h\d+/i)) { // match h1, h2, h3...
-    return elem;
-  }
-
-  return null;
-};
-
-Crowi.unblinkSelectedSection = function(hash) {
-  const elem = Crowi.findSectionHeader(hash);
-  if (elem != null) {
-    elem.classList.remove('blink');
-  }
-};
-
-Crowi.blinkSelectedSection = function(hash) {
-  const elem = Crowi.findSectionHeader(hash);
-  if (elem != null) {
-    elem.classList.add('blink');
-  }
-};
-
-window.addEventListener('load', () => {
-  const { appContainer } = window;
-  const pageContainer = appContainer.getContainer('PageContainer');
-
-  // Do nothing if the page does not exist
-  // ex.) admin page,login page
-  if (pageContainer == null) {
-    return null;
-  }
-  const { isAbleToOpenPageEditor } = pageContainer;
-
-  // hash on page
-  if (window.location.hash) {
-    const navigationContainer = appContainer.getContainer('NavigationContainer');
-
-    if (window.location.hash === '#edit' && isAbleToOpenPageEditor) {
-      navigationContainer.setEditorMode('edit');
-
-      // focus
-      Crowi.setCaretLineAndFocusToEditor();
-    }
-    else if (window.location.hash === '#hackmd') {
-      navigationContainer.setEditorMode('hackmd');
-    }
-  }
-});
+// window.addEventListener('load', () => {
+//   const { appContainer } = window;
+//   const pageContainer = appContainer.getContainer('PageContainer');
+
+//   // Do nothing if the page does not exist
+//   // ex.) admin page,login page
+//   if (pageContainer == null) {
+//     return null;
+//   }
+//   const { isAbleToOpenPageEditor } = pageContainer;
+
+//   // hash on page
+//   if (window.location.hash) {
+//     const navigationContainer = appContainer.getContainer('NavigationContainer');
+
+//     if (window.location.hash === '#edit' && isAbleToOpenPageEditor) {
+//       navigationContainer.setEditorMode('edit');
+
+//       // focus
+//       Crowi.setCaretLineAndFocusToEditor();
+//     }
+//     else if (window.location.hash === '#hackmd') {
+//       navigationContainer.setEditorMode('hackmd');
+//     }
+//   }
+// });
 
 window.addEventListener('load', () => {
   const crowi = window.crowi;
@@ -219,28 +177,22 @@ window.addEventListener('load', () => {
     });
   }
 
-  Crowi.blinkSelectedSection(window.location.hash);
+  blinkSectionHeaderAtBoot();
+
   Crowi.modifyScrollTop();
   Crowi.initClassesByOS();
 });
 
 window.addEventListener('hashchange', (e) => {
-  Crowi.unblinkSelectedSection(Crowi.findHashFromUrl(e.oldURL));
-  Crowi.blinkSelectedSection(Crowi.findHashFromUrl(e.newURL));
   Crowi.modifyScrollTop();
-  const { appContainer } = window;
-  const navigationContainer = appContainer.getContainer('NavigationContainer');
-
 
   // hash on page
   if (window.location.hash) {
     if (window.location.hash === '#edit') {
-      navigationContainer.setEditorMode('edit');
       Crowi.setCaretLineAndFocusToEditor();
     }
-    else if (window.location.hash === '#hackmd') {
-      navigationContainer.setEditorMode('hackmd');
-    }
+    // else if (window.location.hash === '#hackmd') {
+    // }
   }
 });
 

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

+ 40 - 8
packages/app/src/client/services/ContextExtractor.tsx

@@ -1,4 +1,4 @@
-import React, { FC } from 'react';
+import React, { FC, useEffect, useState } from 'react';
 import { pagePathUtils } from '@growi/core';
 
 import {
@@ -6,13 +6,19 @@ 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, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
+} from '~/stores/ui';
+import { IUserUISettings } from '~/interfaces/user-ui-settings';
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
 
 const jsonNull = 'null';
 
-const ContextExtractor: FC = () => {
+const ContextExtractorOnce: FC = () => {
 
   const mainContent = document.querySelector('#content-main');
 
@@ -21,6 +27,11 @@ const ContextExtractor: 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
    */
@@ -50,13 +61,20 @@ const ContextExtractor: 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
   useCreatedAt(createdAt);
   useDeleteUsername(deleteUsername);
@@ -85,11 +103,25 @@ const ContextExtractor: FC = () => {
   useCreator(creator);
   useRevisionAuthor(revisionAuthor);
 
-  return (
-    <div>
-      {/* Render nothing */}
-    </div>
-  );
+  // Navigation
+  usePreferDrawerModeByUser();
+  usePreferDrawerModeOnEditByUser();
+  useIsDeviceSmallerThanMd();
+
+  // Editor
+  useSlackChannels(slackChannels);
+
+  return null;
 };
 
+const ContextExtractor: FC = React.memo(() => {
+  const [isRunOnce, setRunOnce] = useState(false);
+
+  useEffect(() => {
+    setRunOnce(true);
+  }, []);
+
+  return isRunOnce ? null : <ContextExtractorOnce></ContextExtractorOnce>;
+});
+
 export default ContextExtractor;

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

+ 0 - 239
packages/app/src/client/services/NavigationContainer.js

@@ -1,239 +0,0 @@
-import { Container } from 'unstated';
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:services:NavigationContainer');
-
-/**
- * Service container related to options for Application
- * @extends {Container} unstated Container
- */
-
-const SCROLL_THRES_SKIP = 200;
-const WIKI_HEADER_LINK = 120;
-
-export default class NavigationContainer extends Container {
-
-  constructor(appContainer) {
-    super();
-
-    this.appContainer = appContainer;
-    this.appContainer.registerContainer(this);
-
-    const { localStorage } = window;
-
-    this.state = {
-      editorMode: 'view',
-
-      isDeviceSmallerThanMd: null,
-      preferDrawerModeByUser: localStorage.preferDrawerModeByUser === 'true',
-      preferDrawerModeOnEditByUser: // default: true
-        localStorage.preferDrawerModeOnEditByUser == null || localStorage.preferDrawerModeOnEditByUser === 'true',
-      isDrawerMode: null,
-      isDrawerOpened: false,
-
-      sidebarContentsId: localStorage.sidebarContentsId || 'recent',
-
-      isScrollTop: true,
-
-      isPageCreateModalShown: false,
-    };
-
-    this.openPageCreateModal = this.openPageCreateModal.bind(this);
-    this.closePageCreateModal = this.closePageCreateModal.bind(this);
-    this.setEditorMode = this.setEditorMode.bind(this);
-    this.initDeviceSize();
-    this.initScrollEvent();
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'NavigationContainer';
-  }
-
-  getPageContainer() {
-    return this.appContainer.getContainer('PageContainer');
-  }
-
-  initDeviceSize() {
-    const mdOrAvobeHandler = async(mql) => {
-      let isDeviceSmallerThanMd;
-
-      // sm -> md
-      if (mql.matches) {
-        isDeviceSmallerThanMd = false;
-      }
-      // md -> sm
-      else {
-        isDeviceSmallerThanMd = true;
-      }
-
-      this.setState({ isDeviceSmallerThanMd });
-      this.updateDrawerMode({ ...this.state, isDeviceSmallerThanMd }); // generate newest state object
-    };
-
-    this.appContainer.addBreakpointListener('md', mdOrAvobeHandler, true);
-  }
-
-  initScrollEvent() {
-    window.addEventListener('scroll', () => {
-      const currentYOffset = window.pageYOffset;
-
-      // original throttling
-      if (SCROLL_THRES_SKIP < currentYOffset) {
-        return;
-      }
-
-      this.setState({
-        isScrollTop: currentYOffset === 0,
-      });
-    });
-  }
-
-  setEditorMode(editorMode) {
-    const { isNotCreatable } = this.getPageContainer().state;
-
-    if (this.appContainer.currentUser == null) {
-      logger.warn('Please login or signup to edit the page or use hackmd.');
-      return;
-    }
-
-    if (isNotCreatable) {
-      logger.warn('This page could not edit.');
-      return;
-    }
-
-    this.setState({ editorMode });
-    if (editorMode === 'view') {
-      $('body').removeClass('on-edit');
-      $('body').removeClass('builtin-editor');
-      $('body').removeClass('hackmd');
-      $('body').removeClass('pathname-sidebar');
-      window.history.replaceState(null, '', window.location.pathname);
-    }
-
-    if (editorMode === 'edit') {
-      $('body').addClass('on-edit');
-      $('body').addClass('builtin-editor');
-      $('body').removeClass('hackmd');
-      // editing /Sidebar
-      if (window.location.pathname === '/Sidebar') {
-        $('body').addClass('pathname-sidebar');
-      }
-      window.location.hash = '#edit';
-    }
-
-    if (editorMode === 'hackmd') {
-      $('body').addClass('on-edit');
-      $('body').addClass('hackmd');
-      $('body').removeClass('builtin-editor');
-      $('body').removeClass('pathname-sidebar');
-      window.location.hash = '#hackmd';
-    }
-
-    this.updateDrawerMode({ ...this.state, editorMode }); // generate newest state object
-  }
-
-  toggleDrawer() {
-    const { isDrawerOpened } = this.state;
-    this.setState({ isDrawerOpened: !isDrawerOpened });
-  }
-
-  /**
-   * Set Sidebar mode preference by user
-   * @param {boolean} preferDockMode
-   */
-  async setDrawerModePreference(bool) {
-    this.setState({ preferDrawerModeByUser: bool });
-    this.updateDrawerMode({ ...this.state, preferDrawerModeByUser: bool }); // generate newest state object
-
-    // store settings to localStorage
-    const { localStorage } = window;
-    localStorage.preferDrawerModeByUser = bool;
-  }
-
-  /**
-   * Set Sidebar mode preference by user
-   * @param {boolean} preferDockMode
-   */
-  async setDrawerModePreferenceOnEdit(bool) {
-    this.setState({ preferDrawerModeOnEditByUser: bool });
-    this.updateDrawerMode({ ...this.state, preferDrawerModeOnEditByUser: bool }); // generate newest state object
-
-    // store settings to localStorage
-    const { localStorage } = window;
-    localStorage.preferDrawerModeOnEditByUser = bool;
-  }
-
-  /**
-   * Update drawer related state by specified 'newState' object
-   * @param {object} newState A newest state object
-   *
-   * Specify 'newState' like following code:
-   *
-   *   { ...this.state, overwriteParam: overwriteValue }
-   *
-   * because updating state of unstated container will be delayed unless you use await
-   */
-  updateDrawerMode(newState) {
-    const {
-      editorMode, isDeviceSmallerThanMd, preferDrawerModeByUser, preferDrawerModeOnEditByUser,
-    } = newState;
-
-    // get preference on view or edit
-    const preferDrawerMode = editorMode !== 'view' ? preferDrawerModeOnEditByUser : preferDrawerModeByUser;
-
-    const isDrawerMode = isDeviceSmallerThanMd || preferDrawerMode;
-    const isDrawerOpened = false; // close Drawer anyway
-
-    this.setState({ isDrawerMode, isDrawerOpened });
-  }
-
-  selectSidebarContents(contentsId) {
-    window.localStorage.setItem('sidebarContentsId', contentsId);
-    this.setState({ sidebarContentsId: contentsId });
-  }
-
-  openPageCreateModal() {
-    if (this.appContainer.currentUser == null) {
-      logger.warn('Please login or signup to create a new page.');
-      return;
-    }
-    this.setState({ isPageCreateModalShown: true });
-  }
-
-  closePageCreateModal() {
-    this.setState({ isPageCreateModalShown: false });
-  }
-
-  /**
-   * Function that implements the click event for realizing smooth scroll
-   * @param {array} elements
-   */
-  addSmoothScrollEvent(elements = {}) {
-    elements.forEach(link => link.addEventListener('click', (e) => {
-      e.preventDefault();
-
-      const href = link.getAttribute('href').replace('#', '');
-      window.location.hash = href;
-      const targetDom = document.getElementById(href);
-      this.smoothScrollIntoView(targetDom, WIKI_HEADER_LINK);
-    }));
-  }
-
-  smoothScrollIntoView(element = null, offsetTop = 0) {
-    const targetElement = element || window.document.body;
-
-    // get the distance to the target element top
-    const rectTop = targetElement.getBoundingClientRect().top;
-
-    const top = window.pageYOffset + rectTop - offsetTop;
-
-    window.scrollTo({
-      top,
-      behavior: 'smooth',
-    });
-  }
-
-}

+ 5 - 21
packages/app/src/client/services/PageContainer.js

@@ -161,13 +161,6 @@ export default class PageContainer extends Container {
   }
 
 
-  get isAbleToOpenPageEditor() {
-    const { isNotCreatable, isTrashPage } = this.state;
-    const { isGuestUser } = this.appContainer;
-
-    return (!isNotCreatable && !isTrashPage && !isGuestUser);
-  }
-
   /**
    * whether to display reaction buttons
    * ex.) like, bookmark
@@ -350,10 +343,6 @@ export default class PageContainer extends Container {
     }
   }
 
-  get navigationContainer() {
-    return this.appContainer.getContainer('NavigationContainer');
-  }
-
   setLatestRemotePageData(s2cMessagePageUpdated) {
     const newState = {
       remoteRevisionId: s2cMessagePageUpdated.revisionId,
@@ -380,9 +369,7 @@ export default class PageContainer extends Container {
    * @param {Array[Tag]} tags Array of Tag
    * @param {object} revision Revision instance
    */
-  updateStateAfterSave(page, tags, revision) {
-    const { editorMode } = this.navigationContainer.state;
-
+  updateStateAfterSave(page, tags, revision, editorMode) {
     // update state of PageContainer
     const newState = {
       pageId: page._id,
@@ -426,9 +413,7 @@ export default class PageContainer extends Container {
    * @param {object} optionsToSave
    * @return {object} { page: Page, tags: Tag[] }
    */
-  async save(markdown, optionsToSave = {}) {
-    const { editorMode } = this.navigationContainer.state;
-
+  async save(markdown, editorMode, optionsToSave = {}) {
     const { pageId, path } = this.state;
     let { revisionId } = this.state;
 
@@ -448,19 +433,18 @@ export default class PageContainer extends Container {
       res = await this.updatePage(pageId, revisionId, markdown, options);
     }
 
-    this.updateStateAfterSave(res.page, res.tags, res.revision);
+    this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
     return res;
   }
 
-  async saveAndReload(optionsToSave) {
+  async saveAndReload(optionsToSave, editorMode) {
     if (optionsToSave == null) {
       const msg = '\'saveAndReload\' requires the \'optionsToSave\' param';
       throw new Error(msg);
     }
 
-    const { editorMode } = this.navigationContainer.state;
     if (editorMode == null) {
-      logger.warn('\'saveAndReload\' requires the \'errorMode\' param');
+      logger.warn('\'saveAndReload\' requires the \'editorMode\' param');
       return;
     }
 

+ 28 - 0
packages/app/src/client/services/user-ui-settings.ts

@@ -0,0 +1,28 @@
+// eslint-disable-next-line no-restricted-imports
+import { AxiosResponse } from 'axios';
+
+import { debounce } from 'throttle-debounce';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { IUserUISettings } from '~/interfaces/user-ui-settings';
+
+let settingsForBulk: Partial<IUserUISettings> = {};
+const _putUserUISettingsInBulk = (): Promise<AxiosResponse<IUserUISettings>> => {
+  const result = apiv3Put<IUserUISettings>('/user-ui-settings', { settings: settingsForBulk });
+
+  // clear partial
+  settingsForBulk = {};
+
+  return result;
+};
+
+const _putUserUISettingsInBulkDebounced = debounce(1500, false, _putUserUISettingsInBulk);
+
+export const scheduleToPutUserUISettings = (settings: Partial<IUserUISettings>): Promise<AxiosResponse<IUserUISettings>> => {
+  settingsForBulk = {
+    ...settingsForBulk,
+    ...settings,
+  };
+
+  return _putUserUISettingsInBulkDebounced();
+};

+ 27 - 0
packages/app/src/client/util/blink-section-header.ts

@@ -0,0 +1,27 @@
+let lastBlinkedElem;
+
+export const blinkElem = (elem: HTMLElement): void => {
+  if (lastBlinkedElem != null) {
+    lastBlinkedElem.classList.remove('blink');
+  }
+
+  elem.classList.add('blink');
+  lastBlinkedElem = elem;
+};
+
+export const blinkSectionHeaderAtBoot = (): HTMLElement | undefined => {
+  const { hash } = window.location;
+
+  if (hash.length === 0) {
+    return;
+  }
+
+  // omit '#'
+  const id = hash.replace('#', '');
+  // don't use jQuery and document.querySelector
+  //  because hash may containe Base64 encoded strings
+  const elem = document.getElementById(id);
+  if (elem != null && elem.tagName.match(/h\d+/i)) { // match h1, h2, h3...
+    blinkElem(elem);
+  }
+};

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

+ 45 - 0
packages/app/src/client/util/smooth-scroll.ts

@@ -0,0 +1,45 @@
+const WIKI_HEADER_LINK = 120;
+
+export const smoothScrollIntoView = (element: HTMLElement, offsetTop = 0): void => {
+  const targetElement = element || window.document.body;
+
+  // get the distance to the target element top
+  const rectTop = targetElement.getBoundingClientRect().top;
+
+  const top = window.pageYOffset + rectTop - offsetTop;
+
+  window.scrollTo({
+    top,
+    behavior: 'smooth',
+  });
+};
+
+export type SmoothScrollEventCallback = (elem: HTMLElement) => void;
+
+export const addSmoothScrollEvent = (elements: HTMLAnchorElement[], callback?: SmoothScrollEventCallback): void => {
+  elements.forEach((link) => {
+    const href = link.getAttribute('href');
+
+    if (href == null) {
+      return;
+    }
+
+    link.addEventListener('click', (e) => {
+      e.preventDefault();
+
+      // modify location.hash without scroll
+      window.history.pushState({}, '', link.href);
+
+      // smooth scroll
+      const elemId = href.replace('#', '');
+      const targetDom = document.getElementById(elemId);
+      if (targetDom != null) {
+        smoothScrollIntoView(targetDom, WIKI_HEADER_LINK);
+
+        if (callback != null) {
+          callback(targetDom);
+        }
+      }
+    });
+  });
+};

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

+ 6 - 7
packages/app/src/components/ContentLinkButtons.jsx

@@ -3,12 +3,12 @@ import PropTypes from 'prop-types';
 
 import { pagePathUtils } from '@growi/core';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 
 import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
+import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 
 const { isTopPage } = pagePathUtils;
 
@@ -20,7 +20,7 @@ const WIKI_HEADER_LINK = 120;
  */
 const ContentLinkButtons = (props) => {
 
-  const { appContainer, navigationContainer, pageContainer } = props;
+  const { appContainer, pageContainer } = props;
   const { pageUser, path } = pageContainer.state;
   const { isPageExist } = pageContainer.state;
   const { isSharedUser } = appContainer;
@@ -39,7 +39,7 @@ const ContentLinkButtons = (props) => {
         <button
           type="button"
           className="btn btn-outline-secondary btn-sm btn-block"
-          onClick={() => navigationContainer.smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
+          onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
         >
           <i className="mr-2 icon-fw icon-bubbles"></i>
           <span>Comments</span>
@@ -53,7 +53,7 @@ const ContentLinkButtons = (props) => {
       <button
         type="button"
         className="btn btn-outline-secondary btn-sm px-2"
-        onClick={() => navigationContainer.smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
+        onClick={() => smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
       >
         <i className="mr-2 icon-star"></i>
         <span>Bookmarks</span>
@@ -67,7 +67,7 @@ const ContentLinkButtons = (props) => {
       <button
         type="button"
         className="btn btn-outline-secondary btn-sm px-3"
-        onClick={() => navigationContainer.smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
+        onClick={() => smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
       >
         <i className="grw-icon-container-recently-created mr-2"><RecentlyCreatedIcon /></i>
         <span>Recently Created</span>
@@ -90,8 +90,7 @@ const ContentLinkButtons = (props) => {
 
 ContentLinkButtons.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
 
-export default withUnstatedContainers(ContentLinkButtons, [AppContainer, NavigationContainer, PageContainer]);
+export default withUnstatedContainers(ContentLinkButtons, [AppContainer, PageContainer]);

+ 40 - 0
packages/app/src/components/EventListeneres/HashChanged.tsx

@@ -0,0 +1,40 @@
+import { FC, useCallback, useEffect } from 'react';
+
+import { EditorMode, useEditorMode } from '~/stores/ui';
+import { useIsEditable } from '~/stores/context';
+
+const HashChanged: FC<void> = () => {
+  const { data: isEditable } = useIsEditable();
+  const { mutate: mutateEditorMode } = useEditorMode();
+
+  const hashchangeHandler = useCallback(() => {
+    const { hash } = window.location;
+
+    if (hash == null) {
+      return;
+    }
+
+    if (hash === '#edit') {
+      mutateEditorMode(EditorMode.Editor);
+    }
+  }, [mutateEditorMode]);
+
+  // setup effect
+  useEffect(() => {
+    if (!isEditable) {
+      return;
+    }
+
+    window.addEventListener('hashchange', hashchangeHandler);
+
+    // return remove handler
+    return function cleanup() {
+      window.removeEventListener('hashchange', hashchangeHandler);
+    };
+
+  }, [hashchangeHandler, isEditable, mutateEditorMode]);
+
+  return null;
+};
+
+export default HashChanged;

+ 9 - 6
packages/app/src/components/Fab.jsx

@@ -5,7 +5,9 @@ import loggerFactory from '~/utils/logger';
 
 
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
+import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
+import { usePageCreateModalOpened } from '~/stores/ui';
+
 import { withUnstatedContainers } from './UnstatedUtils';
 import CreatePageIcon from './Icons/CreatePageIcon';
 import ReturnTopIcon from './Icons/ReturnTopIcon';
@@ -13,9 +15,11 @@ import ReturnTopIcon from './Icons/ReturnTopIcon';
 const logger = loggerFactory('growi:cli:Fab');
 
 const Fab = (props) => {
-  const { navigationContainer, appContainer } = props;
+  const { appContainer } = props;
   const { currentUser } = appContainer;
 
+  const { mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
+
   const [animateClasses, setAnimateClasses] = useState('invisible');
   const [buttonClasses, setButtonClasses] = useState('');
 
@@ -52,7 +56,7 @@ const Fab = (props) => {
           <button
             type="button"
             className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light ${buttonClasses}`}
-            onClick={navigationContainer.openPageCreateModal}
+            onClick={() => mutatePageCreateModalOpened(true)}
           >
             <CreatePageIcon />
           </button>
@@ -68,7 +72,7 @@ const Fab = (props) => {
         <button
           type="button"
           className={`btn btn-light btn-scroll-to-top rounded-circle p-0 ${buttonClasses}`}
-          onClick={() => navigationContainer.smoothScrollIntoView()}
+          onClick={() => smoothScrollIntoView()}
         >
           <ReturnTopIcon />
         </button>
@@ -80,7 +84,6 @@ const Fab = (props) => {
 
 Fab.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 
-export default withUnstatedContainers(Fab, [AppContainer, NavigationContainer]);
+export default withUnstatedContainers(Fab, [AppContainer]);

+ 9 - 11
packages/app/src/components/Hotkeys/Subscribers/CreatePage.jsx

@@ -1,31 +1,29 @@
 import React, { useEffect } from 'react';
 import PropTypes from 'prop-types';
 
-import NavigationContainer from '~/client/services/NavigationContainer';
-import { withUnstatedContainers } from '../../UnstatedUtils';
+import { usePageCreateModalOpened } from '~/stores/ui';
 
-const CreatePage = (props) => {
+const CreatePage = React.memo((props) => {
+
+  const { mutate } = usePageCreateModalOpened();
 
   // setup effect
   useEffect(() => {
-    props.navigationContainer.openPageCreateModal();
+    mutate(true);
 
     // remove this
     props.onDeleteRender(this);
-  }, [props]);
+  }, [mutate, props]);
 
   return <></>;
-};
+});
 
 CreatePage.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   onDeleteRender: PropTypes.func.isRequired,
 };
 
-const CreatePageWrapper = withUnstatedContainers(CreatePage, [NavigationContainer]);
-
-CreatePageWrapper.getHotkeyStrokes = () => {
+CreatePage.getHotkeyStrokes = () => {
   return [['c']];
 };
 
-export default CreatePageWrapper;
+export default CreatePage;

+ 13 - 10
packages/app/src/components/Hotkeys/Subscribers/EditPage.jsx

@@ -1,36 +1,39 @@
 import React, { useEffect } from 'react';
 import PropTypes from 'prop-types';
 
-import NavigationContainer from '~/client/services/NavigationContainer';
-import { withUnstatedContainers } from '../../UnstatedUtils';
+import { EditorMode, useEditorMode } from '~/stores/ui';
+import { useIsEditable } from '~/stores/context';
 
 const EditPage = (props) => {
+  const { data: isEditable } = useIsEditable();
+  const { mutate: mutateEditorMode } = useEditorMode();
 
   // setup effect
   useEffect(() => {
+    if (!isEditable) {
+      return;
+    }
+
     // ignore when dom that has 'modal in' classes exists
     if (document.getElementsByClassName('modal in').length > 0) {
       return;
     }
 
-    props.navigationContainer.setEditorMode('edit');
+    mutateEditorMode(EditorMode.Editor);
 
     // remove this
     props.onDeleteRender(this);
-  }, [props]);
+  }, [isEditable, mutateEditorMode, props]);
 
-  return <></>;
+  return null;
 };
 
 EditPage.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   onDeleteRender: PropTypes.func.isRequired,
 };
 
-const EditPageWrapper = withUnstatedContainers(EditPage, [NavigationContainer]);
-
-EditPageWrapper.getHotkeyStrokes = () => {
+EditPage.getHotkeyStrokes = () => {
   return [['e']];
 };
 
-export default EditPageWrapper;
+export default EditPage;

+ 3 - 3
packages/app/src/components/Icons/GrowiLogo.jsx

@@ -1,6 +1,6 @@
-import React from 'react';
+import React, { memo } from 'react';
 
-const GrowiLogo = () => (
+const GrowiLogo = memo(() => (
   <svg
     xmlns="http://www.w3.org/2000/svg"
     width="32"
@@ -29,6 +29,6 @@ const GrowiLogo = () => (
     >
     </path>
   </svg>
-);
+));
 
 export default GrowiLogo;

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

+ 0 - 46
packages/app/src/components/Navbar/DrawerToggler.jsx

@@ -1,46 +0,0 @@
-import React, { useCallback } from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import NavigationContainer from '~/client/services/NavigationContainer';
-
-const DrawerToggler = (props) => {
-
-  const { navigationContainer } = props;
-
-  const clickHandler = useCallback(() => {
-    navigationContainer.toggleDrawer();
-  }, [navigationContainer]);
-
-  const iconClass = props.iconClass || 'icon-menu';
-
-  return (
-    <button
-      className="grw-drawer-toggler btn btn-secondary"
-      type="button"
-      aria-expanded="false"
-      aria-label="Toggle navigation"
-      onClick={clickHandler}
-    >
-      <i className={iconClass}></i>
-    </button>
-  );
-
-};
-
-/**
- * Wrapper component for using unstated
- */
-const DrawerTogglerWrapper = withUnstatedContainers(DrawerToggler, [NavigationContainer]);
-
-
-DrawerToggler.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-
-  iconClass: PropTypes.string,
-};
-
-export default withTranslation()(DrawerTogglerWrapper);

+ 28 - 0
packages/app/src/components/Navbar/DrawerToggler.tsx

@@ -0,0 +1,28 @@
+import React, { FC } from 'react';
+import { useDrawerOpened } from '~/stores/ui';
+
+type Props = {
+  iconClass?: string,
+}
+
+const DrawerToggler: FC<Props> = (props: Props) => {
+
+  const { data: isOpened, mutate } = useDrawerOpened();
+
+  const iconClass = props.iconClass || 'icon-menu';
+
+  return (
+    <button
+      className="grw-drawer-toggler btn btn-secondary"
+      type="button"
+      aria-expanded="false"
+      aria-label="Toggle navigation"
+      onClick={() => mutate(!isOpened)}
+    >
+      <i className={iconClass}></i>
+    </button>
+  );
+
+};
+
+export default DrawerToggler;

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

@@ -4,7 +4,6 @@ import { withTranslation } from 'react-i18next';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
 
 import SearchForm from '../SearchForm';
 
@@ -97,7 +96,6 @@ class GlobalSearch extends React.Component {
 GlobalSearch.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 
   dropup: PropTypes.bool,
 };
@@ -105,6 +103,6 @@ GlobalSearch.propTypes = {
 /**
  * Wrapper component for using unstated
  */
-const GlobalSearchWrapper = withUnstatedContainers(GlobalSearch, [AppContainer, NavigationContainer]);
+const GlobalSearchWrapper = withUnstatedContainers(GlobalSearch, [AppContainer]);
 
 export default withTranslation()(GlobalSearchWrapper);

+ 0 - 115
packages/app/src/components/Navbar/GrowiNavbar.jsx

@@ -1,115 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import { UncontrolledTooltip } from 'reactstrap';
-import { withUnstatedContainers } from '../UnstatedUtils';
-import NavigationContainer from '~/client/services/NavigationContainer';
-import AppContainer from '~/client/services/AppContainer';
-
-
-import GrowiLogo from '../Icons/GrowiLogo';
-
-import PersonalDropdown from './PersonalDropdown';
-import GlobalSearch from './GlobalSearch';
-
-class GrowiNavbar extends React.Component {
-
-  renderNavbarRight() {
-    const { t, appContainer, navigationContainer } = this.props;
-    const { currentUser } = appContainer;
-
-    // render login button
-    if (currentUser == null) {
-      return <li id="login-user" className="nav-item"><a className="nav-link" href="/login">Login</a></li>;
-    }
-
-    return (
-      <>
-        <li className="nav-item d-none d-md-block">
-          <button className="px-md-2 nav-link btn-create-page border-0 bg-transparent" type="button" onClick={navigationContainer.openPageCreateModal}>
-            <i className="icon-pencil mr-2"></i>
-            <span className="d-none d-lg-block">{ t('New') }</span>
-          </button>
-        </li>
-
-        <li className="grw-personal-dropdown nav-item dropdown dropdown-toggle dropdown-toggle-no-caret">
-          <PersonalDropdown />
-        </li>
-      </>
-    );
-  }
-
-  renderConfidential() {
-    const { appContainer } = this.props;
-    const { crowi } = appContainer.config;
-
-    return (
-      <li className="nav-item confidential text-light">
-        <i id="confidentialTooltip" className="icon-info d-md-none" />
-        <span className="d-none d-md-inline">
-          {crowi.confidential}
-        </span>
-        <UncontrolledTooltip
-          placement="bottom"
-          target="confidentialTooltip"
-          className="d-md-none"
-        >
-          {crowi.confidential}
-        </UncontrolledTooltip>
-      </li>
-    );
-  }
-
-  render() {
-    const { appContainer, navigationContainer } = this.props;
-    const { crowi, isSearchServiceConfigured } = appContainer.config;
-    const { isDeviceSmallerThanMd } = navigationContainer.state;
-
-    return (
-      <>
-
-        {/* Brand Logo  */}
-        <div className="navbar-brand mr-0">
-          <a className="grw-logo d-block" href="/">
-            <GrowiLogo />
-          </a>
-        </div>
-
-        <div className="grw-app-title d-none d-md-block">
-          {crowi.title}
-        </div>
-
-
-        {/* Navbar Right  */}
-        <ul className="navbar-nav ml-auto">
-          {this.renderNavbarRight()}
-          {crowi.confidential != null && this.renderConfidential()}
-        </ul>
-
-        { isSearchServiceConfigured && !isDeviceSmallerThanMd && (
-          <div className="grw-global-search grw-global-search-top position-absolute">
-            <GlobalSearch />
-          </div>
-        ) }
-      </>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const GrowiNavbarWrapper = withUnstatedContainers(GrowiNavbar, [AppContainer, NavigationContainer]);
-
-
-GrowiNavbar.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-};
-
-export default withTranslation()(GrowiNavbarWrapper);

+ 128 - 0
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -0,0 +1,128 @@
+import React, { FC, memo } from 'react';
+import PropTypes from 'prop-types';
+
+import { useTranslation } from 'react-i18next';
+
+import { UncontrolledTooltip } from 'reactstrap';
+
+import AppContainer from '~/client/services/AppContainer';
+import { IUser } from '~/interfaces/user';
+import { useIsDeviceSmallerThanMd, usePageCreateModalOpened } from '~/stores/ui';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+import GrowiLogo from '../Icons/GrowiLogo';
+
+import PersonalDropdown from './PersonalDropdown';
+import GlobalSearch from './GlobalSearch';
+
+type NavbarRightProps = {
+  currentUser: IUser,
+}
+const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
+  const { t } = useTranslation();
+  const { mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
+
+  const { currentUser } = props;
+
+  // render login button
+  if (currentUser == null) {
+    return <li id="login-user" className="nav-item"><a className="nav-link" href="/login">Login</a></li>;
+  }
+
+  return (
+    <>
+      <li className="nav-item d-none d-md-block">
+        <button
+          className="px-md-2 nav-link btn-create-page border-0 bg-transparent"
+          type="button"
+          onClick={() => mutatePageCreateModalOpened(true)}
+        >
+          <i className="icon-pencil mr-2"></i>
+          <span className="d-none d-lg-block">{ t('New') }</span>
+        </button>
+      </li>
+
+      <li className="grw-personal-dropdown nav-item dropdown dropdown-toggle dropdown-toggle-no-caret">
+        <PersonalDropdown />
+      </li>
+    </>
+  );
+});
+
+type ConfidentialProps = {
+  confidential?: string,
+}
+const Confidential: FC<ConfidentialProps> = memo((props: ConfidentialProps) => {
+  const { confidential } = props;
+
+  if (confidential == null) {
+    return null;
+  }
+
+  return (
+    <li className="nav-item confidential text-light">
+      <i id="confidentialTooltip" className="icon-info d-md-none" />
+      <span className="d-none d-md-inline">
+        {confidential}
+      </span>
+      <UncontrolledTooltip
+        placement="bottom"
+        target="confidentialTooltip"
+        className="d-md-none"
+      >
+        {confidential}
+      </UncontrolledTooltip>
+    </li>
+  );
+});
+
+
+const GrowiNavbar = (props) => {
+
+  const { appContainer } = props;
+  const { currentUser } = appContainer;
+  const { crowi, isSearchServiceConfigured } = appContainer.config;
+
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+
+  return (
+    <>
+      {/* Brand Logo  */}
+      <div className="navbar-brand mr-0">
+        <a className="grw-logo d-block" href="/">
+          <GrowiLogo />
+        </a>
+      </div>
+
+      <div className="grw-app-title d-none d-md-block">
+        {crowi.title}
+      </div>
+
+
+      {/* Navbar Right  */}
+      <ul className="navbar-nav ml-auto">
+        <NavbarRight currentUser={currentUser}></NavbarRight>
+        <Confidential confidential={crowi.confidential}></Confidential>
+      </ul>
+
+      { isSearchServiceConfigured && !isDeviceSmallerThanMd && (
+        <div className="grw-global-search grw-global-search-top position-absolute">
+          <GlobalSearch />
+        </div>
+      ) }
+    </>
+  );
+
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GrowiNavbarWrapper = withUnstatedContainers(GrowiNavbar, [AppContainer]);
+
+
+GrowiNavbar.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+export default GrowiNavbarWrapper;

+ 7 - 12
packages/app/src/components/Navbar/GrowiNavbarBottom.jsx

@@ -1,17 +1,15 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import NavigationContainer from '~/client/services/NavigationContainer';
-import { withUnstatedContainers } from '../UnstatedUtils';
+import { usePageCreateModalOpened, useIsDeviceSmallerThanMd, useDrawerOpened } from '~/stores/ui';
 
 import GlobalSearch from './GlobalSearch';
 
 const GrowiNavbarBottom = (props) => {
 
-  const {
-    navigationContainer,
-  } = props;
-  const { isDrawerOpened, isDeviceSmallerThanMd } = navigationContainer.state;
+  const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+  const { mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
 
   const additionalClasses = ['grw-navbar-bottom'];
   if (isDrawerOpened) {
@@ -36,7 +34,7 @@ const GrowiNavbarBottom = (props) => {
             <a
               role="button"
               className="nav-link btn-lg"
-              onClick={() => navigationContainer.toggleDrawer()}
+              onClick={() => mutateDrawerOpened(true)}
             >
               <i className="icon-menu"></i>
             </a>
@@ -55,7 +53,7 @@ const GrowiNavbarBottom = (props) => {
             <a
               role="button"
               className="nav-link btn-lg"
-              onClick={() => navigationContainer.openPageCreateModal()}
+              onClick={() => mutatePageCreateModalOpened(true)}
             >
               <i className="icon-pencil"></i>
             </a>
@@ -67,8 +65,5 @@ const GrowiNavbarBottom = (props) => {
   );
 };
 
-GrowiNavbarBottom.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-};
 
-export default withUnstatedContainers(GrowiNavbarBottom, [NavigationContainer]);
+export default GrowiNavbarBottom;

+ 13 - 12
packages/app/src/components/Navbar/GrowiSubNavigation.jsx

@@ -1,16 +1,16 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import { withTranslation } from 'react-i18next';
-
 import { DevidedPagePath } from '@growi/core';
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
 import LinkedPagePath from '~/models/linked-page-path';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
+import {
+  EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd,
+} from '~/stores/ui';
 
 import CopyDropdown from '../Page/CopyDropdown';
 import TagLabels from '../Page/TagLabels';
@@ -67,21 +67,24 @@ const PagePathNav = ({
 };
 
 const GrowiSubNavigation = (props) => {
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+  const { data: isDrawerMode } = useDrawerMode();
+  const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
+
   const {
-    appContainer, navigationContainer, pageContainer, isCompactMode,
+    appContainer, pageContainer, isCompactMode,
   } = props;
-  const { isDrawerMode, editorMode, isDeviceSmallerThanMd } = navigationContainer.state;
   const {
     pageId, path, createdAt, creator, updatedAt, revisionAuthor, isPageExist,
   } = pageContainer.state;
 
   const { isGuestUser } = appContainer;
-  const isEditorMode = editorMode !== 'view';
+  const isEditorMode = editorMode !== EditorMode.View;
   // Tags cannot be edited while the new page and editorMode is view
-  const isTagLabelHidden = (editorMode !== 'edit' && !isPageExist);
+  const isTagLabelHidden = (editorMode !== EditorMode.Editor && !isPageExist);
 
   function onPageEditorModeButtonClicked(viewType) {
-    navigationContainer.setEditorMode(viewType);
+    mutateEditorMode(viewType);
   }
 
   return (
@@ -145,16 +148,14 @@ const GrowiSubNavigation = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [AppContainer, NavigationContainer, PageContainer]);
+const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [AppContainer, PageContainer]);
 
 
 GrowiSubNavigation.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
   isCompactMode: PropTypes.bool,
 };
 
-export default withTranslation()(GrowiSubNavigationWrapper);
+export default GrowiSubNavigationWrapper;

+ 17 - 15
packages/app/src/components/Navbar/PageEditorModeManager.jsx

@@ -1,10 +1,12 @@
 import React, { useCallback } from 'react';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 
-import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
+import { EditorMode, useIsDeviceSmallerThanMd } from '~/stores/ui';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 /* eslint-disable react/prop-types */
 const PageEditorModeButtonWrapper = React.memo(({
@@ -36,14 +38,17 @@ const PageEditorModeButtonWrapper = React.memo(({
 
 function PageEditorModeManager(props) {
   const {
-    t, appContainer,
-    editorMode, onPageEditorModeButtonClicked, isBtnDisabled, isDeviceSmallerThanMd,
+    appContainer,
+    editorMode, onPageEditorModeButtonClicked, isBtnDisabled,
   } = props;
 
+  const { t } = useTranslation();
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+
   const isAdmin = appContainer.isAdmin;
   const isHackmdEnabled = appContainer.config.env.HACKMD_URI != null;
   const showHackmdBtn = isHackmdEnabled || isAdmin;
-  const showHackmdDisabledTooltip = isAdmin && !isHackmdEnabled && editorMode !== 'hackmd';
+  const showHackmdDisabledTooltip = isAdmin && !isHackmdEnabled && editorMode !== EditorMode.HackMD;
 
   const pageEditorModeButtonClickedHandler = useCallback((viewType) => {
     if (isBtnDisabled) {
@@ -62,32 +67,32 @@ function PageEditorModeManager(props) {
         aria-label="page-editor-mode-manager"
         id="grw-page-editor-mode-manager"
       >
-        {(!isDeviceSmallerThanMd || editorMode !== 'view') && (
+        {(!isDeviceSmallerThanMd || editorMode !== EditorMode.View) && (
           <PageEditorModeButtonWrapper
             editorMode={editorMode}
             isBtnDisabled={isBtnDisabled}
             onClick={pageEditorModeButtonClickedHandler}
-            targetMode="view"
+            targetMode={EditorMode.View}
             icon={<i className="icon-control-play" />}
             label={t('view')}
           />
         )}
-        {(!isDeviceSmallerThanMd || editorMode === 'view') && (
+        {(!isDeviceSmallerThanMd || editorMode === EditorMode.View) && (
           <PageEditorModeButtonWrapper
             editorMode={editorMode}
             isBtnDisabled={isBtnDisabled}
             onClick={pageEditorModeButtonClickedHandler}
-            targetMode="edit"
+            targetMode={EditorMode.Editor}
             icon={<i className="icon-note" />}
             label={t('Edit')}
           />
         )}
-        {(!isDeviceSmallerThanMd || editorMode === 'view') && showHackmdBtn && (
+        {(!isDeviceSmallerThanMd || editorMode === EditorMode.View) && showHackmdBtn && (
           <PageEditorModeButtonWrapper
             editorMode={editorMode}
             isBtnDisabled={isBtnDisabled}
             onClick={pageEditorModeButtonClickedHandler}
-            targetMode="hackmd"
+            targetMode={EditorMode.HackMD}
             icon={<i className="fa fa-file-text-o" />}
             label={t('hackmd.hack_md')}
             id="grw-page-editor-mode-manager-hackmd-button"
@@ -110,18 +115,15 @@ function PageEditorModeManager(props) {
 }
 
 PageEditorModeManager.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
   onPageEditorModeButtonClicked: PropTypes.func,
   isBtnDisabled: PropTypes.bool,
   editorMode: PropTypes.string,
-  isDeviceSmallerThanMd: PropTypes.bool,
 };
 
 PageEditorModeManager.defaultProps = {
   isBtnDisabled: false,
-  isDeviceSmallerThanMd: false,
 };
 
 /**
@@ -129,4 +131,4 @@ PageEditorModeManager.defaultProps = {
  */
 const PageEditorModeManagerWrapper = withUnstatedContainers(PageEditorModeManager, [AppContainer]);
 
-export default withTranslation()(PageEditorModeManagerWrapper);
+export default PageEditorModeManagerWrapper;

+ 21 - 21
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useState, useCallback } from 'react';
 import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
@@ -6,9 +6,12 @@ import { withTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 
 import { UserPicture } from '@growi/ui';
-import { withUnstatedContainers } from '../UnstatedUtils';
+
+import { scheduleToPutUserUISettings } from '~/client/services/user-ui-settings';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+import { usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser } from '~/stores/ui';
 
 import {
   isUserPreferenceExists,
@@ -28,12 +31,15 @@ import SunIcon from '../Icons/SunIcon';
 
 const PersonalDropdown = (props) => {
 
-  const { t, appContainer, navigationContainer } = props;
+  const { t, appContainer } = props;
   const user = appContainer.currentUser || {};
 
   const [useOsSettings, setOsSettings] = useState(!isUserPreferenceExists());
   const [isDarkMode, setIsDarkMode] = useState(isDarkModeByUtil());
 
+  const { data: isPreferDrawerMode, mutate: mutatePreferDrawerMode } = usePreferDrawerModeByUser();
+  const { data: isPreferDrawerModeOnEdit, mutate: mutatePreferDrawerModeOnEdit } = usePreferDrawerModeOnEditByUser();
+
   const logoutHandler = () => {
     const { interceptorManager } = appContainer;
 
@@ -46,13 +52,15 @@ const PersonalDropdown = (props) => {
     window.location.href = '/logout';
   };
 
-  const preferDrawerModeSwitchModifiedHandler = (bool) => {
-    navigationContainer.setDrawerModePreference(bool);
-  };
+  const preferDrawerModeSwitchModifiedHandler = useCallback((bool) => {
+    mutatePreferDrawerMode(bool);
+    scheduleToPutUserUISettings({ preferDrawerModeByUser: bool });
+  }, [mutatePreferDrawerMode]);
 
-  const preferDrawerModeOnEditSwitchModifiedHandler = (bool) => {
-    navigationContainer.setDrawerModePreferenceOnEdit(bool);
-  };
+  const preferDrawerModeOnEditSwitchModifiedHandler = useCallback((bool) => {
+    mutatePreferDrawerModeOnEdit(bool);
+    scheduleToPutUserUISettings({ preferDrawerModeOnEditByUser: bool });
+  }, [mutatePreferDrawerModeOnEdit]);
 
   const followOsCheckboxModifiedHandler = (bool) => {
     if (bool) {
@@ -77,13 +85,6 @@ const PersonalDropdown = (props) => {
   };
 
 
-  /*
-   * render
-   */
-  const {
-    preferDrawerModeByUser, preferDrawerModeOnEditByUser,
-  } = navigationContainer.state;
-
   /* eslint-disable react/prop-types */
   const IconWithTooltip = ({
     id, label, children, additionalClasses,
@@ -144,7 +145,7 @@ const PersonalDropdown = (props) => {
                   id="swSidebarMode"
                   className="custom-control-input"
                   type="checkbox"
-                  checked={!preferDrawerModeByUser}
+                  checked={!isPreferDrawerMode}
                   onChange={e => preferDrawerModeSwitchModifiedHandler(!e.target.checked)}
                 />
                 <label className="custom-control-label" htmlFor="swSidebarMode"></label>
@@ -169,7 +170,7 @@ const PersonalDropdown = (props) => {
                   id="swSidebarModeOnEditor"
                   className="custom-control-input"
                   type="checkbox"
-                  checked={!preferDrawerModeOnEditByUser}
+                  checked={!isPreferDrawerModeOnEdit}
                   onChange={e => preferDrawerModeOnEditSwitchModifiedHandler(!e.target.checked)}
                 />
                 <label className="custom-control-label" htmlFor="swSidebarModeOnEditor"></label>
@@ -236,13 +237,12 @@ const PersonalDropdown = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const PersonalDropdownWrapper = withUnstatedContainers(PersonalDropdown, [AppContainer, NavigationContainer]);
+const PersonalDropdownWrapper = withUnstatedContainers(PersonalDropdown, [AppContainer]);
 
 
 PersonalDropdown.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 
 export default withTranslation()(PersonalDropdownWrapper);

+ 5 - 7
packages/app/src/components/Navbar/SubNavButtons.jsx

@@ -1,8 +1,8 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 import BookmarkButton from '../BookmarkButton';
@@ -11,10 +11,10 @@ import PageManagement from '../Page/PageManagement';
 
 const SubnavButtons = (props) => {
   const {
-    appContainer, navigationContainer, pageContainer, isCompactMode,
+    appContainer, pageContainer, isCompactMode,
   } = props;
 
-  /* eslint-enable react/prop-types */
+  const { data: editorMode } = useEditorMode();
 
   /* eslint-disable react/prop-types */
   const PageReactionButtons = ({ pageContainer }) => {
@@ -34,8 +34,7 @@ const SubnavButtons = (props) => {
   };
   /* eslint-enable react/prop-types */
 
-  const { editorMode } = navigationContainer.state;
-  const isViewMode = editorMode === 'view';
+  const isViewMode = editorMode === EditorMode.View;
 
   return (
     <>
@@ -52,12 +51,11 @@ const SubnavButtons = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const SubnavButtonsWrapper = withUnstatedContainers(SubnavButtons, [AppContainer, NavigationContainer, PageContainer]);
+const SubnavButtonsWrapper = withUnstatedContainers(SubnavButtons, [AppContainer, PageContainer]);
 
 
 SubnavButtons.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
   isCompactMode: PropTypes.bool,

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

@@ -17,6 +17,13 @@ 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');
 
 class Page extends React.Component {
@@ -70,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,
@@ -85,7 +94,7 @@ class Page extends React.Component {
       editorContainer.disableUnsavedWarning();
 
       // eslint-disable-next-line no-unused-vars
-      const { page, tags } = await pageContainer.save(newMarkdown, optionsToSave);
+      const { page, tags } = await pageContainer.save(newMarkdown, this.props.editorMode, optionsToSave);
       logger.debug('success to save');
 
       pageContainer.showSuccessToastr();
@@ -100,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,
@@ -115,7 +126,7 @@ class Page extends React.Component {
       editorContainer.disableUnsavedWarning();
 
       // eslint-disable-next-line no-unused-vars
-      const { page, tags } = await pageContainer.save(newMarkdown, optionsToSave);
+      const { page, tags } = await pageContainer.save(newMarkdown, this.props.editorMode, optionsToSave);
       logger.debug('success to save');
 
       pageContainer.showSuccessToastr();
@@ -157,6 +168,30 @@ Page.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
+  // 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: editorMode } = useEditorMode();
+  const { data: isSlackEnabled } = useIsSlackEnabled();
+  const { data: slackChannels } = useSlackChannels();
+
+  if (editorMode == null) {
+    return null;
+  }
+
+  return (
+    <Page
+      {...props}
+      editorMode={editorMode}
+      isSlackEnabled={isSlackEnabled}
+      slackChannels={slackChannels}
+    />
+  );
 };
 
-export default withUnstatedContainers(Page, [AppContainer, PageContainer, EditorContainer]);
+export default withUnstatedContainers(PageWrapper, [AppContainer, PageContainer, EditorContainer]);

+ 30 - 17
packages/app/src/components/Page/DisplaySwitcher.jsx

@@ -1,9 +1,11 @@
 import React from 'react';
 import { TabContent, TabPane } from 'reactstrap';
 import propTypes from 'prop-types';
+
 import { withUnstatedContainers } from '../UnstatedUtils';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
+import { EditorMode, useEditorMode } from '~/stores/ui';
+
 import Editor from '../PageEditor';
 import Page from '../Page';
 import UserInfo from '../User/UserInfo';
@@ -12,19 +14,25 @@ import ContentLinkButtons from '../ContentLinkButtons';
 import PageAccessories from '../PageAccessories';
 import PageEditorByHackmd from '../PageEditorByHackmd';
 import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
+import HashChanged from '../EventListeneres/HashChanged';
+import { useIsEditable } from '~/stores/context';
 
 
 const DisplaySwitcher = (props) => {
   const {
-    navigationContainer, pageContainer,
+    pageContainer,
   } = props;
-  const { editorMode } = navigationContainer.state;
   const { isPageExist, pageUser } = pageContainer.state;
 
+  const { data: isEditable } = useIsEditable();
+  const { data: editorMode } = useEditorMode();
+
+  const isViewMode = editorMode === EditorMode.View;
+
   return (
     <>
       <TabContent activeTab={editorMode}>
-        <TabPane tabId="view">
+        <TabPane tabId={EditorMode.View}>
           <div className="d-flex flex-column flex-lg-row-reverse">
 
             <div className="grw-side-contents-container">
@@ -49,26 +57,31 @@ const DisplaySwitcher = (props) => {
 
           </div>
         </TabPane>
-        <TabPane tabId="edit">
-          <div id="page-editor">
-            <Editor />
-          </div>
-        </TabPane>
-        <TabPane tabId="hackmd">
-          <div id="page-editor-with-hackmd">
-            <PageEditorByHackmd />
-          </div>
-        </TabPane>
+        { isEditable && (
+          <TabPane tabId={EditorMode.Editor}>
+            <div id="page-editor">
+              <Editor />
+            </div>
+          </TabPane>
+        ) }
+        { isEditable && (
+          <TabPane tabId={EditorMode.HackMD}>
+            <div id="page-editor-with-hackmd">
+              <PageEditorByHackmd />
+            </div>
+          </TabPane>
+        ) }
       </TabContent>
-      {editorMode !== 'view' && <EditorNavbarBottom /> }
+      { isEditable && !isViewMode && <EditorNavbarBottom /> }
+
+      { isEditable && <HashChanged></HashChanged> }
     </>
   );
 };
 
 DisplaySwitcher.propTypes = {
-  navigationContainer: propTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: propTypes.instanceOf(PageContainer).isRequired,
 };
 
 
-export default withUnstatedContainers(DisplaySwitcher, [NavigationContainer, PageContainer]);
+export default withUnstatedContainers(DisplaySwitcher, [PageContainer]);

+ 13 - 13
packages/app/src/components/Page/NotFoundAlert.jsx

@@ -1,24 +1,26 @@
-import React from 'react';
+import React, { useCallback } from 'react';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 
 
 const NotFoundAlert = (props) => {
-  const { t, isHidden, isGuestUserMode } = props;
-  function clickHandler(viewType) {
+  const { t } = useTranslation();
+  const { isHidden, isGuestUserMode } = props;
 
+  const { mutate: mutateEditorMode } = useEditorMode();
+
+  const clickHandler = useCallback(() => {
     // check guest user,
     // disabled of button cannot be used for using tooltip.
     if (isGuestUserMode) {
       return;
     }
 
-    if (props.onPageCreateClicked === null) {
-      return;
-    }
-    props.onPageCreateClicked(viewType);
-  }
+    mutateEditorMode(EditorMode.Editor);
+
+  }, [isGuestUserMode, mutateEditorMode]);
 
   if (isHidden) {
     return null;
@@ -38,7 +40,7 @@ const NotFoundAlert = (props) => {
           <button
             type="button"
             className={`pl-3 pr-3 btn bg-info text-white ${isGuestUserMode ? 'disabled' : ''}`}
-            onClick={() => { clickHandler('edit') }}
+            onClick={clickHandler}
           >
             <i className="icon-note icon-fw" />
             {t('not_found_page.Create Page')}
@@ -58,10 +60,8 @@ const NotFoundAlert = (props) => {
 
 
 NotFoundAlert.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  onPageCreateClicked: PropTypes.func,
   isHidden: PropTypes.bool.isRequired,
   isGuestUserMode: PropTypes.bool.isRequired,
 };
 
-export default withTranslation()(NotFoundAlert);
+export default NotFoundAlert;

+ 21 - 8
packages/app/src/components/Page/RevisionRenderer.jsx

@@ -3,12 +3,13 @@ import PropTypes from 'prop-types';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import GrowiRenderer from '~/client/util/GrowiRenderer';
+import { addSmoothScrollEvent } from '~/client/util/smooth-scroll';
+import { blinkElem } from '~/client/util/blink-section-header';
 
 import RevisionBody from './RevisionBody';
 
-class RevisionRenderer extends React.PureComponent {
+class LegacyRevisionRenderer extends React.PureComponent {
 
   constructor(props) {
     super(props);
@@ -35,7 +36,7 @@ class RevisionRenderer extends React.PureComponent {
 
   componentDidUpdate(prevProps) {
     const { markdown: prevMarkdown, highlightKeywords: prevHighlightKeywords } = prevProps;
-    const { markdown, highlightKeywords, navigationContainer } = this.props;
+    const { markdown, highlightKeywords } = this.props;
 
     // render only when props.markdown is updated
     if (markdown !== prevMarkdown || highlightKeywords !== prevHighlightKeywords) {
@@ -46,7 +47,7 @@ class RevisionRenderer extends React.PureComponent {
 
     const HeaderLink = document.getElementsByClassName('revision-head-link');
     const HeaderLinkArray = Array.from(HeaderLink);
-    navigationContainer.addSmoothScrollEvent(HeaderLinkArray);
+    addSmoothScrollEvent(HeaderLinkArray, blinkElem);
 
     const { interceptorManager } = this.props.appContainer;
 
@@ -117,18 +118,30 @@ class RevisionRenderer extends React.PureComponent {
 
 }
 
+LegacyRevisionRenderer.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
+  markdown: PropTypes.string.isRequired,
+  highlightKeywords: PropTypes.string,
+  additionalClassName: PropTypes.string,
+};
+
 /**
  * Wrapper component for using unstated
  */
-const RevisionRendererWrapper = withUnstatedContainers(RevisionRenderer, [AppContainer, NavigationContainer]);
+const LegacyRevisionRendererWrapper = withUnstatedContainers(LegacyRevisionRenderer, [AppContainer]);
+
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+const RevisionRenderer = (props) => {
+  return <LegacyRevisionRendererWrapper {...props} />;
+};
 
 RevisionRenderer.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
   highlightKeywords: PropTypes.string,
   additionalClassName: PropTypes.string,
 };
 
-export default RevisionRendererWrapper;
+export default RevisionRenderer;

+ 4 - 3
packages/app/src/components/Page/TagLabels.jsx

@@ -11,6 +11,7 @@ import EditorContainer from '~/client/services/EditorContainer';
 
 import RenderTagLabels from './RenderTagLabels';
 import TagEditModal from './TagEditModal';
+import { EditorMode } from '~/stores/ui';
 
 class TagLabels extends React.Component {
 
@@ -33,7 +34,7 @@ class TagLabels extends React.Component {
    */
   getTagData() {
     const { editorContainer, pageContainer, editorMode } = this.props;
-    return (editorMode === 'edit') ? editorContainer.state.tags : pageContainer.state.tags;
+    return (editorMode === EditorMode.Editor) ? editorContainer.state.tags : pageContainer.state.tags;
   }
 
   openEditorModal() {
@@ -52,7 +53,7 @@ class TagLabels extends React.Component {
     const { pageId } = pageContainer.state;
 
     // It will not be reflected in the DB until the page is refreshed
-    if (editorMode === 'edit') {
+    if (editorMode === EditorMode.Editor) {
       return editorContainer.setState({ tags: newTags });
     }
 
@@ -116,7 +117,7 @@ TagLabels.propTypes = {
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 
-  editorMode: PropTypes.string.isRequired,
+  editorMode: PropTypes.string,
 };
 
 export default withTranslation()(TagLabelsWrapper);

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

+ 1 - 0
packages/app/src/components/PageContentFooter.jsx

@@ -6,6 +6,7 @@ import AuthorInfo from './Navbar/AuthorInfo';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
+import { usePath } from '~/stores/context';
 
 const PageContentFooter = (props) => {
   const { pageContainer } = props;

+ 8 - 7
packages/app/src/components/PageCreateModal.jsx

@@ -11,9 +11,9 @@ import { pagePathUtils, pathUtils } from '@growi/core';
 
 
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
+import { usePageCreateModalOpened } from '~/stores/ui';
 
 import PagePathAutoComplete from './PagePathAutoComplete';
 
@@ -22,7 +22,9 @@ const {
 } = pagePathUtils;
 
 const PageCreateModal = (props) => {
-  const { t, appContainer, navigationContainer } = props;
+  const { t, appContainer } = props;
+
+  const { data: isPageCreateModalOpened, mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
 
   const config = appContainer.getConfig();
   const isReachable = config.isSearchServiceReachable;
@@ -264,12 +266,12 @@ const PageCreateModal = (props) => {
   return (
     <Modal
       size="lg"
-      isOpen={navigationContainer.state.isPageCreateModalShown}
-      toggle={navigationContainer.closePageCreateModal}
+      isOpen={isPageCreateModalOpened}
+      toggle={() => mutatePageCreateModalOpened(false)}
       className="grw-create-page"
       autoFocus={false}
     >
-      <ModalHeader tag="h4" toggle={navigationContainer.closePageCreateModal} className="bg-primary text-light">
+      <ModalHeader tag="h4" toggle={() => mutatePageCreateModalOpened(false)} className="bg-primary text-light">
         {t('New Page')}
       </ModalHeader>
       <ModalBody>
@@ -286,13 +288,12 @@ const PageCreateModal = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const ModalControlWrapper = withUnstatedContainers(PageCreateModal, [AppContainer, NavigationContainer]);
+const ModalControlWrapper = withUnstatedContainers(PageCreateModal, [AppContainer]);
 
 
 PageCreateModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 
 export default withTranslation()(ModalControlWrapper);

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

@@ -15,6 +15,13 @@ 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, useSlackChannels } from '~/stores/context';
+import { useIsSlackEnabled } from '~/stores/editor';
+
 const logger = loggerFactory('growi:PageEditor');
 
 class PageEditor extends React.Component {
@@ -124,15 +131,18 @@ 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
       editorContainer.disableUnsavedWarning();
 
       // eslint-disable-next-line no-unused-vars
-      const { page, tags } = await pageContainer.save(this.state.markdown, optionsToSave);
+      const { page, tags } = await pageContainer.save(this.state.markdown, this.props.editorMode, optionsToSave);
       logger.debug('success to save');
 
       pageContainer.showSuccessToastr();
@@ -186,7 +196,7 @@ class PageEditor extends React.Component {
       // when if created newly
       if (res.pageCreated) {
         logger.info('Page is created', res.page._id);
-        pageContainer.updateStateAfterSave(res.page, res.tags, res.revision);
+        pageContainer.updateStateAfterSave(res.page, res.tags, res.revision, this.props.editorMode);
         editorContainer.setState({ grant: res.page.grant });
       }
     }
@@ -306,6 +316,10 @@ class PageEditor extends React.Component {
   }
 
   render() {
+    if (!this.props.isEditable) {
+      return null;
+    }
+
     const config = this.props.appContainer.getConfig();
     const noCdn = envUtils.toBoolean(config.env.NO_CDN);
     const emojiStrategy = this.props.appContainer.getEmojiStrategy();
@@ -347,12 +361,40 @@ class PageEditor extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const PageEditorWrapper = withUnstatedContainers(PageEditor, [AppContainer, PageContainer, EditorContainer]);
+const PageEditorHOCWrapper = withUnstatedContainers(PageEditor, [AppContainer, PageContainer, EditorContainer]);
+
+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}
+      isSlackEnabled={isSlackEnabled}
+      slackChannels={slackChannels}
+    />
+  );
+};
 
 PageEditor.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
+  isEditable: PropTypes.bool.isRequired,
+
+  // TODO: remove this when omitting unstated is completed
+  editorMode: PropTypes.string.isRequired,
+  isSlackEnabled: PropTypes.bool.isRequired,
+  slackChannels: PropTypes.string.isRequired,
 };
 
 export default PageEditorWrapper;

+ 5 - 2
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -264,7 +264,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 +280,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);
   }
 

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

@@ -1,48 +1,57 @@
-import React, { useState } from 'react';
+import React, { useCallback, useState } from 'react';
 import PropTypes from 'prop-types';
 
 import { Collapse, Button } from 'reactstrap';
 
-import NavigationContainer from '~/client/services/NavigationContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import AppContainer from '~/client/services/AppContainer';
-import SlackNotification from '../SlackNotification';
+import {
+  EditorMode, useDrawerOpened, useEditorMode, useIsDeviceSmallerThanMd,
+} from '~/stores/ui';
+
+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) => {
 
+  const { data: editorMode } = useEditorMode();
+
   const [isExpanded, setExpanded] = useState(false);
 
   const [isSlackExpanded, setSlackExpanded] = useState(false);
   const isSlackConfigured = props.appContainer.getConfig().isSlackConfigured;
 
-  const {
-    navigationContainer,
-  } = props;
-  const { editorMode, isDeviceSmallerThanMd } = navigationContainer.state;
-
+  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" className="btn btn-outline-secondary border-0" onClick={() => navigationContainer.toggleDrawer()}>
+    <button
+      type="button"
+      className="btn btn-outline-secondary border-0"
+      onClick={() => mutateDrawerOpened(true)}
+    >
       <i className="icon-menu"></i>
     </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
@@ -55,22 +64,21 @@ const EditorNavbarBottom = (props) => {
     </div>
   );
 
-  const isOptionsSelectorEnabled = editorMode !== 'hackmd';
+  const isOptionsSelectorEnabled = editorMode !== EditorMode.HackMD;
   const isCollapsedOptionsSelectorEnabled = isOptionsSelectorEnabled && isDeviceSmallerThanMd;
 
   return (
     <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>
@@ -97,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>
           ))}
@@ -127,9 +134,8 @@ const EditorNavbarBottom = (props) => {
 };
 
 EditorNavbarBottom.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 
-export default withUnstatedContainers(EditorNavbarBottom, [NavigationContainer, EditorContainer, AppContainer]);
+export default withUnstatedContainers(EditorNavbarBottom, [EditorContainer, AppContainer]);

+ 2 - 1
packages/app/src/components/PageEditor/LinkEditModal.jsx

@@ -162,7 +162,8 @@ class LinkEditModal extends React.PureComponent {
       const pageId = isPermanentLink ? pathWithoutFragment.slice(1) : null;
 
       try {
-        const { page } = await this.props.appContainer.apiGet('/pages.get', { path: pathWithoutFragment, page_id: pageId });
+        const { data } = await this.props.appContainer.apiv3Get('/page', { path: pathWithoutFragment, page_id: pageId });
+        const { page } = data;
         markdown = page.revision.body;
         permalink = page.id;
       }

+ 37 - 4
packages/app/src/components/PageEditorByHackmd.jsx

@@ -11,6 +11,13 @@ 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');
 
 class PageEditorByHackmd extends React.Component {
@@ -163,15 +170,17 @@ 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
       editorContainer.disableUnsavedWarning();
 
       // eslint-disable-next-line no-unused-vars
-      const { page, tags } = await pageContainer.save(markdown, optionsToSave);
+      const { page, tags } = await pageContainer.save(markdown, this.props.editorMode, optionsToSave);
       logger.debug('success to save');
 
       pageContainer.showSuccessToastr();
@@ -417,7 +426,26 @@ class PageEditorByHackmd extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const PageEditorByHackmdWrapper = withUnstatedContainers(PageEditorByHackmd, [AppContainer, PageContainer, EditorContainer]);
+const PageEditorByHackmdHOCWrapper = withUnstatedContainers(PageEditorByHackmd, [AppContainer, PageContainer, EditorContainer]);
+
+const PageEditorByHackmdWrapper = (props) => {
+  const { data: editorMode } = useEditorMode();
+  const { data: isSlackEnabled } = useIsSlackEnabled();
+  const { data: slackChannels } = useSlackChannels();
+
+  if (editorMode == null) {
+    return null;
+  }
+
+  return (
+    <PageEditorByHackmdHOCWrapper
+      {...props}
+      editorMode={editorMode}
+      isSlackEnabled={isSlackEnabled}
+      slackChannels={slackChannels}
+    />
+  );
+};
 
 PageEditorByHackmd.propTypes = {
   t: PropTypes.func.isRequired, // i18next
@@ -425,6 +453,11 @@ PageEditorByHackmd.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
+  // TODO: remove this when omitting unstated is completed
+  editorMode: PropTypes.string.isRequired,
+  isSlackEnabled: PropTypes.bool.isRequired,
+  slackChannels: PropTypes.string.isRequired,
 };
 
 export default withTranslation()(PageEditorByHackmdWrapper);

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

@@ -17,6 +17,13 @@ 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, useSlackChannels } from '~/stores/context';
+import { useIsSlackEnabled } from '~/stores/editor';
+
 const logger = loggerFactory('growi:SavePageControls');
 
 class SavePageControls extends React.Component {
@@ -31,6 +38,7 @@ class SavePageControls extends React.Component {
 
     this.save = this.save.bind(this);
     this.saveAndOverwriteScopesOfDescendants = this.saveAndOverwriteScopesOfDescendants.bind(this);
+
   }
 
   updateGrantHandler(data) {
@@ -38,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());
+      const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, editorContainer);
+      await pageContainer.saveAndReload(optionsToSave, this.props.editorMode);
     }
     catch (error) {
       logger.error('failed to save', error);
@@ -53,14 +64,17 @@ 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);
+    pageContainer.saveAndReload(optionsToSave, this.props.editorMode);
   }
 
   render() {
@@ -107,7 +121,31 @@ class SavePageControls extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const SavePageControlsWrapper = withUnstatedContainers(SavePageControls, [AppContainer, PageContainer, EditorContainer]);
+const SavePageControlsHOCWrapper = withUnstatedContainers(SavePageControls, [AppContainer, PageContainer, EditorContainer]);
+
+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;
+  }
+
+  if (!isEditable) {
+    return null;
+  }
+
+  return (
+    <SavePageControlsHOCWrapper
+      {...props}
+      editorMode={editorMode}
+      isSlackEnabled={isSlackEnabled}
+      slackChannels={slackChannels}
+    />
+  );
+};
 
 SavePageControls.propTypes = {
   t: PropTypes.func.isRequired, // i18next
@@ -115,6 +153,11 @@ SavePageControls.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
+  // TODO: remove this when omitting unstated is completed
+  editorMode: PropTypes.string.isRequired,
+  isSlackEnabled: PropTypes.bool.isRequired,
+  slackChannels: PropTypes.string.isRequired,
 };
 
 export default withTranslation()(SavePageControlsWrapper);

+ 0 - 242
packages/app/src/components/Sidebar.jsx

@@ -1,242 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import {
-  withNavigationUIController,
-  LayoutManager,
-  NavigationProvider,
-  ThemeProvider,
-} from '@atlaskit/navigation-next';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
-
-import DrawerToggler from './Navbar/DrawerToggler';
-
-import SidebarNav from './Sidebar/SidebarNav';
-import SidebarContents from './Sidebar/SidebarContents';
-import StickyStretchableScroller from './StickyStretchableScroller';
-
-const sidebarDefaultWidth = 320;
-
-class Sidebar extends React.Component {
-
-  static propTypes = {
-    appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-    navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-    navigationUIController: PropTypes.any.isRequired,
-    isDrawerModeOnInit: PropTypes.bool,
-  };
-
-  componentWillMount() {
-    this.hackUIController();
-  }
-
-  componentDidUpdate(prevProps, prevState) {
-    this.toggleDrawerMode(this.isDrawerMode);
-  }
-
-  /**
-   * 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);
-    };
-  }
-
-  /**
-   * return whether drawer mode or not
-   */
-  get isDrawerMode() {
-    let isDrawerMode = this.props.navigationContainer.state.isDrawerMode;
-    if (isDrawerMode == null) {
-      isDrawerMode = this.props.isDrawerModeOnInit;
-    }
-    return isDrawerMode;
-  }
-
-  toggleDrawerMode(bool) {
-    const { navigationUIController } = this.props;
-
-    const isStateModified = navigationUIController.state.isResizeDisabled !== bool;
-    if (!isStateModified) {
-      return;
-    }
-
-    // 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');
-      }
-
-      navigationUIController.disableResize();
-
-      // fix width
-      navigationUIController.setState({ productNavWidth: sidebarDefaultWidth });
-    }
-    // Drawer --> Dock
-    else {
-      // clear transition temporary
-      if (this.sidebarCollapsedCached) {
-        this.addCssClassTemporary('grw-sidebar-supress-transitions-to-dock');
-      }
-
-      navigationUIController.enableResize();
-
-      // restore width
-      if (this.sidebarWidthCached != null) {
-        navigationUIController.setState({ productNavWidth: this.sidebarWidthCached });
-      }
-    }
-  }
-
-  get sidebarElem() {
-    return document.querySelector('.grw-sidebar');
-  }
-
-  addCssClassTemporary(className) {
-    // clear
-    this.sidebarElem.classList.add(className);
-
-    // restore after 300ms
-    setTimeout(() => {
-      this.sidebarElem.classList.remove(className);
-    }, 300);
-  }
-
-  backdropClickedHandler = () => {
-    const { navigationContainer } = this.props;
-    navigationContainer.toggleDrawer();
-  }
-
-  itemSelectedHandler = (contentsId) => {
-    const { navigationContainer, navigationUIController } = this.props;
-    const { sidebarContentsId } = navigationContainer.state;
-
-    // already selected
-    if (sidebarContentsId === contentsId) {
-      navigationUIController.toggleCollapse();
-    }
-    // switch and expand
-    else {
-      navigationUIController.expand();
-    }
-  }
-
-  calcViewHeight() {
-    const scrollTargetElem = document.querySelector('#grw-sidebar-contents-scroll-target');
-    return window.innerHeight - scrollTargetElem.getBoundingClientRect().top;
-  }
-
-  renderGlobalNavigation = () => (
-    <SidebarNav onItemSelected={this.itemSelectedHandler} />
-  );
-
-  renderSidebarContents = () => {
-    const scrollTargetSelector = '#grw-sidebar-contents-scroll-target';
-
-    return (
-      <>
-        <StickyStretchableScroller
-          scrollTargetSelector={scrollTargetSelector}
-          contentsElemSelector="#grw-sidebar-content-container"
-          stickyElemSelector=".grw-sidebar"
-          calcViewHeightFunc={this.calcViewHeight}
-        />
-
-        <div id="grw-sidebar-contents-scroll-target">
-          <div id="grw-sidebar-content-container">
-            <SidebarContents
-              isSharedUser={this.props.appContainer.isSharedUser}
-            />
-          </div>
-        </div>
-
-        <DrawerToggler iconClass="icon-arrow-left" />
-      </>
-    );
-  };
-
-  render() {
-    const { isDrawerOpened } = this.props.navigationContainer.state;
-
-    return (
-      <>
-        <div className={`grw-sidebar d-print-none ${this.isDrawerMode ? 'grw-sidebar-drawer' : ''} ${isDrawerOpened ? 'open' : ''}`}>
-          <ThemeProvider
-            theme={theme => ({
-              ...theme,
-              context: 'product',
-            })}
-          >
-            <LayoutManager
-              globalNavigation={this.renderGlobalNavigation}
-              productNavigation={() => null}
-              containerNavigation={this.renderSidebarContents}
-              experimental_hideNavVisuallyOnCollapse
-              experimental_flyoutOnHover
-              experimental_alternateFlyoutBehaviour
-              experimental_fullWidthFlyout
-              shouldHideGlobalNavShadow
-              showContextualNavigation
-            >
-            </LayoutManager>
-          </ThemeProvider>
-        </div>
-
-        { isDrawerOpened && (
-          <div className="grw-sidebar-backdrop modal-backdrop show" onClick={this.backdropClickedHandler}></div>
-        ) }
-      </>
-    );
-  }
-
-}
-
-
-const SidebarWithNavigationUIController = withNavigationUIController(Sidebar);
-
-/**
- * Wrapper component for using unstated
- */
-
-const SidebarWithNavigation = (props) => {
-  const { preferDrawerModeByUser: isDrawerModeOnInit } = props.navigationContainer.state;
-
-  const initUICForDrawerMode = isDrawerModeOnInit
-    // generate initialUIController for Drawer mode
-    ? {
-      isCollapsed: false,
-      isResizeDisabled: true,
-      productNavWidth: sidebarDefaultWidth,
-    }
-    // set undefined (should be initialized by cache)
-    : undefined;
-
-  return (
-    <NavigationProvider initialUIController={initUICForDrawerMode}>
-      <SidebarWithNavigationUIController {...props} isDrawerModeOnInit={isDrawerModeOnInit} />
-    </NavigationProvider>
-  );
-};
-
-SidebarWithNavigation.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-};
-
-export default withUnstatedContainers(SidebarWithNavigation, [AppContainer, NavigationContainer]);

+ 282 - 0
packages/app/src/components/Sidebar.tsx

@@ -0,0 +1,282 @@
+import React, {
+  FC, useCallback, useEffect, useRef, useState,
+} from 'react';
+
+import { scheduleToPutUserUISettings } from '~/client/services/user-ui-settings';
+import {
+  useDrawerMode, useDrawerOpened,
+  useSidebarCollapsed,
+  useCurrentSidebarContents,
+  useCurrentProductNavWidth,
+  useSidebarResizeDisabled,
+} from '~/stores/ui';
+
+import DrawerToggler from './Navbar/DrawerToggler';
+
+import SidebarNav from './Sidebar/SidebarNav';
+import SidebarContents from './Sidebar/SidebarContents';
+import { NavigationResizeHexagon } from './Sidebar/NavigationResizeHexagon';
+import StickyStretchableScroller from './StickyStretchableScroller';
+
+const sidebarMinWidth = 240;
+const sidebarMinimizeWidth = 20;
+
+const GlobalNavigation = () => {
+  const { data: currentContents } = useCurrentSidebarContents();
+  const { data: isCollapsed, mutate: mutateSidebarCollapsed } = useSidebarCollapsed();
+
+  const itemSelectedHandler = useCallback((selectedContents) => {
+
+    let newValue = false;
+
+    // already selected
+    if (currentContents === selectedContents) {
+      // toggle collapsed
+      newValue = !isCollapsed;
+    }
+
+    mutateSidebarCollapsed(newValue, false);
+    scheduleToPutUserUISettings({ isSidebarCollapsed: newValue });
+
+  }, [currentContents, isCollapsed, mutateSidebarCollapsed]);
+
+  return <SidebarNav onItemSelected={itemSelectedHandler} />;
+};
+
+const SidebarContentsWrapper = () => {
+  const [resetKey, setResetKey] = useState(0);
+
+  const scrollTargetSelector = '#grw-sidebar-contents-scroll-target';
+
+  const calcViewHeight = useCallback(() => {
+    const scrollTargetElem = document.querySelector('#grw-sidebar-contents-scroll-target');
+    return scrollTargetElem != null
+      ? window.innerHeight - scrollTargetElem?.getBoundingClientRect().top
+      : window.innerHeight;
+  }, []);
+
+  return (
+    <>
+      <StickyStretchableScroller
+        scrollTargetSelector={scrollTargetSelector}
+        contentsElemSelector="#grw-sidebar-content-container"
+        stickyElemSelector=".grw-sidebar"
+        calcViewHeightFunc={calcViewHeight}
+        resetKey={resetKey}
+      />
+
+      <div id="grw-sidebar-contents-scroll-target" style={{ minHeight: '100%' }}>
+        <div id="grw-sidebar-content-container" onLoad={() => setResetKey(Math.random())}>
+          <SidebarContents />
+        </div>
+      </div>
+
+      <DrawerToggler iconClass="icon-arrow-left" />
+    </>
+  );
+};
+
+
+type Props = {
+}
+
+const Sidebar: FC<Props> = (props: Props) => {
+  const { data: isDrawerMode } = useDrawerMode();
+  const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
+  const { data: currentProductNavWidth, mutate: mutateProductNavWidth } = useCurrentProductNavWidth();
+  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 isResizableByDrag = !isResizeDisabled && !isDrawerMode && (!isCollapsed || isHover);
+
+  const toggleDrawerMode = useCallback((bool) => {
+    const isStateModified = isResizeDisabled !== bool;
+    if (!isStateModified) {
+      return;
+    }
+
+    // Drawer <-- Dock
+    if (bool) {
+      // disable resize
+      mutateSidebarResizeDisabled(true, false);
+    }
+    // Drawer --> Dock
+    else {
+      // enable resize
+      mutateSidebarResizeDisabled(false, false);
+    }
+  }, [isResizeDisabled, mutateSidebarResizeDisabled]);
+
+  const backdropClickedHandler = useCallback(() => {
+    mutateDrawerOpened(false, false);
+  }, [mutateDrawerOpened]);
+
+  useEffect(() => {
+    setTimeout(() => {
+      setTransitionEnabled(true);
+    }, 1000);
+  }, []);
+
+  useEffect(() => {
+    toggleDrawerMode(isDrawerMode);
+  }, [isDrawerMode, toggleDrawerMode]);
+
+  const resizableContainer = useRef<HTMLDivElement>(null);
+  const setContentWidth = useCallback((newWidth) => {
+    if (resizableContainer.current == null) {
+      return;
+    }
+    resizableContainer.current.style.width = `${newWidth}px`;
+  }, []);
+
+  const hoverOnResizableContainerHandler = useCallback(() => {
+    if (!isCollapsed || isDrawerMode || isDragging) {
+      return;
+    }
+
+    setHover(true);
+    setContentWidth(currentProductNavWidth);
+  }, [isCollapsed, isDrawerMode, isDragging, setContentWidth, currentProductNavWidth]);
+
+  const hoverOutHandler = useCallback(() => {
+    if (!isCollapsed || isDrawerMode || isDragging) {
+      return;
+    }
+
+    setHover(false);
+    setContentWidth(sidebarMinimizeWidth);
+  }, [isCollapsed, isDragging, isDrawerMode, setContentWidth]);
+
+  const toggleNavigationBtnClickHandler = useCallback(() => {
+    const newValue = !isCollapsed;
+    mutateSidebarCollapsed(newValue, false);
+    scheduleToPutUserUISettings({ isSidebarCollapsed: newValue });
+  }, [isCollapsed, mutateSidebarCollapsed]);
+
+  useEffect(() => {
+    if (isCollapsed) {
+      setContentWidth(sidebarMinimizeWidth);
+    }
+    else {
+      setContentWidth(currentProductNavWidth);
+    }
+  }, [currentProductNavWidth, isCollapsed, setContentWidth]);
+
+  const draggableAreaMoveHandler = useCallback((event: MouseEvent) => {
+    event.preventDefault();
+
+    const newWidth = event.pageX - 60;
+    if (resizableContainer.current != null) {
+      setContentWidth(newWidth);
+      resizableContainer.current.classList.add('dragging');
+    }
+  }, [setContentWidth]);
+
+  const dragableAreaMouseUpHandler = useCallback(() => {
+    if (resizableContainer.current == null) {
+      return;
+    }
+
+    setDrag(false);
+
+    if (resizableContainer.current.clientWidth < sidebarMinWidth) {
+      // force collapsed
+      mutateSidebarCollapsed(true);
+      mutateProductNavWidth(sidebarMinWidth, false);
+      scheduleToPutUserUISettings({ isSidebarCollapsed: true, currentProductNavWidth: sidebarMinWidth });
+    }
+    else {
+      const newWidth = resizableContainer.current.clientWidth;
+      mutateSidebarCollapsed(false);
+      mutateProductNavWidth(newWidth, false);
+      scheduleToPutUserUISettings({ isSidebarCollapsed: false, currentProductNavWidth: newWidth });
+    }
+
+    resizableContainer.current.classList.remove('dragging');
+
+  }, [mutateProductNavWidth, mutateSidebarCollapsed]);
+
+  const dragableAreaMouseDownHandler = useCallback((event: React.MouseEvent) => {
+    if (!isResizableByDrag) {
+      return;
+    }
+
+    event.preventDefault();
+
+    setDrag(true);
+
+    const removeEventListeners = () => {
+      document.removeEventListener('mousemove', draggableAreaMoveHandler);
+      document.removeEventListener('mouseup', dragableAreaMouseUpHandler);
+      document.removeEventListener('mouseup', removeEventListeners);
+    };
+
+    document.addEventListener('mousemove', draggableAreaMoveHandler);
+    document.addEventListener('mouseup', dragableAreaMouseUpHandler);
+    document.addEventListener('mouseup', removeEventListeners);
+
+  }, [dragableAreaMouseUpHandler, draggableAreaMoveHandler, isResizableByDrag]);
+
+  return (
+    <>
+      <div className={`grw-sidebar d-print-none ${isDrawerMode ? 'grw-sidebar-drawer' : ''} ${isDrawerOpened ? 'open' : ''}`}>
+        <div className="data-layout-container">
+          <div className={`navigation ${isTransitionEnabled ? 'transition-enabled' : ''}`} onMouseLeave={hoverOutHandler}>
+            <div className="grw-navigation-wrap">
+              <div className="grw-global-navigation">
+                <GlobalNavigation></GlobalNavigation>
+              </div>
+              <div
+                ref={resizableContainer}
+                className="grw-contextual-navigation"
+                onMouseEnter={hoverOnResizableContainerHandler}
+                style={{ width: isCollapsed ? sidebarMinimizeWidth : currentProductNavWidth }}
+              >
+                <div className="grw-contextual-navigation-child">
+                  <div role="group" className={`grw-contextual-navigation-sub ${!isHover && isCollapsed ? 'collapsed' : ''}`}>
+                    <SidebarContentsWrapper></SidebarContentsWrapper>
+                  </div>
+                </div>
+              </div>
+            </div>
+            <div className="grw-navigation-draggable">
+              { isResizableByDrag && (
+                <div
+                  className="grw-navigation-draggable-hitarea"
+                  onMouseDown={dragableAreaMouseDownHandler}
+                >
+                  <div className="grw-navigation-draggable-hitarea-child"></div>
+                </div>
+              ) }
+              <button
+                className={`grw-navigation-resize-button ${!isDrawerMode ? 'resizable' : ''} ${isCollapsed ? 'collapsed' : ''} `}
+                type="button"
+                aria-expanded="true"
+                aria-label="Toggle navigation"
+                disabled={isDrawerMode}
+                onClick={toggleNavigationBtnClickHandler}
+              >
+                <span className="hexagon-container" role="presentation">
+                  <NavigationResizeHexagon />
+                </span>
+                <span className="hitarea" role="presentation"></span>
+              </button>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      { isDrawerOpened && (
+        <div className="grw-sidebar-backdrop modal-backdrop show" onClick={backdropClickedHandler}></div>
+      ) }
+    </>
+  );
+
+};
+
+export default Sidebar;

+ 17 - 39
packages/app/src/components/Sidebar/CustomSidebar.jsx → packages/app/src/components/Sidebar/CustomSidebar.tsx

@@ -1,13 +1,12 @@
-import React, {
-  useState, useCallback, useEffect,
-} from 'react';
-import PropTypes from 'prop-types';
+import React, { FC } from 'react';
 
+import AppContainer from '~/client/services/AppContainer';
 import loggerFactory from '~/utils/logger';
+import { useSWRxPageByPath } from '~/stores/page';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
 import RevisionRenderer from '../Page/RevisionRenderer';
+import { IRevision } from '~/interfaces/revision';
 
 const logger = loggerFactory('growi:cli:CustomSidebar');
 
@@ -22,55 +21,38 @@ const SidebarNotFound = () => {
   );
 };
 
-const CustomSidebar = (props) => {
-
-  const { appContainer } = props;
-  const { apiGet } = appContainer;
+type Props = {
+  appContainer: AppContainer,
+};
 
-  const [isMounted, setMounted] = useState(false);
-  const [markdown, setMarkdown] = useState();
+const CustomSidebar: FC<Props> = (props: Props) => {
 
-  const growiRenderer = appContainer.getRenderer('sidebar');
+  const { appContainer } = props;
 
-  // TODO: refactor with SWR
-  const fetchDataAndRenderHtml = useCallback(async() => {
-    let page = null;
-    try {
-      const result = await apiGet('/pages.get', { path: '/Sidebar' });
-      page = result.page;
-    }
-    catch (e) {
-      logger.warn(e.message);
-      return;
-    }
-    finally {
-      setMounted(true);
-    }
+  const renderer = appContainer.getRenderer('sidebar');
 
-    setMarkdown(page.revision.body);
-  }, [apiGet]);
+  const { data: page, mutate } = useSWRxPageByPath('/Sidebar');
 
-  useEffect(() => {
-    fetchDataAndRenderHtml();
-  }, [fetchDataAndRenderHtml]);
+  const isLoading = page === undefined;
+  const markdown = (page?.revision as IRevision | undefined)?.body;
 
   return (
     <>
       <div className="grw-sidebar-content-header p-3 d-flex">
-        <h3 className="mb-0 text-nowrap">
+        <h3 className="mb-0">
           Custom Sidebar
           <a className="h6 ml-2" href="/Sidebar"><i className="icon-pencil"></i></a>
         </h3>
-        <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={fetchDataAndRenderHtml}>
+        <button type="button" className="btn btn-sm btn-outline-secondary ml-auto" onClick={() => mutate()}>
           <i className="icon icon-reload"></i>
         </button>
       </div>
-      { isMounted && markdown == null && <SidebarNotFound /> }
+      { !isLoading && markdown == null && <SidebarNotFound /> }
       {/* eslint-disable-next-line react/no-danger */}
       { markdown != null && (
         <div className="p-3">
           <RevisionRenderer
-            growiRenderer={growiRenderer}
+            growiRenderer={renderer}
             markdown={markdown}
             additionalClassName="grw-custom-sidebar-content"
           />
@@ -81,10 +63,6 @@ const CustomSidebar = (props) => {
 
 };
 
-CustomSidebar.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
 /**
  * Wrapper component for using unstated
  */

+ 20 - 0
packages/app/src/components/Sidebar/NavigationResizeHexagon.tsx

@@ -0,0 +1,20 @@
+import React, { FC } from 'react';
+
+type Props = {
+
+};
+
+export const NavigationResizeHexagon: FC<Props> = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 27.691 23.999"
+  >
+    <g className="background" transform="translate(0 0)">
+      <path d="M20.768,0l6.923,12L20.768,24H6.923L0,12,6.923,0Z" transform="translate(0)"></path>
+    </g>
+    <g className="icon" transform="translate(10 6)">
+      { /* eslint-disable-next-line max-len */ }
+      <path d="M2.124,9.114l5.28,5.34a.647.647,0,0,0,.922,0l.616-.623a.665.665,0,0,0,0-.932L4.759,8.648,8.943,4.4a.665.665,0,0,0,0-.932l-.616-.623a.647.647,0,0,0-.922,0l-5.28,5.34A.665.665,0,0,0,2.124,9.114Z" transform="translate(-1.933 -2.648)"></path>
+    </g>
+  </svg>
+);

+ 6 - 100
packages/app/src/components/Sidebar/RecentChanges.jsx → packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -1,16 +1,15 @@
 import React, {
+  FC,
   useCallback, useEffect, useState,
 } from 'react';
 import PropTypes from 'prop-types';
 
-import { useTranslation, withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import { UserPicture } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
 
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
-import { apiv3Get } from '~/client/util/apiv3-client';
-import { toastError } from '~/client/util/apiNotification';
 import { useSWRxRecentlyUpdated } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
@@ -124,14 +123,10 @@ SmallPageItem.propTypes = {
 };
 
 
-const RecentChanges = () => {
+const RecentChanges: FC<void> = () => {
 
   const { t } = useTranslation();
-  const { data: pages, error, mutate } = useSWRxRecentlyUpdated();
-
-  if (error != null) {
-    toastError(error, 'Error occurred in updating History');
-  }
+  const { data: pages, mutate } = useSWRxRecentlyUpdated();
 
   const [isRecentChangesSidebarSmall, setIsRecentChangesSidebarSmall] = useState(false);
 
@@ -139,7 +134,7 @@ const RecentChanges = () => {
     if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
       setIsRecentChangesSidebarSmall(true);
     }
-  });
+  }, []);
 
   const changeSizeHandler = useCallback((e) => {
     setIsRecentChangesSidebarSmall(e.target.checked);
@@ -184,93 +179,4 @@ const RecentChanges = () => {
 
 };
 
-// export default RecentChanges;
-
-
-class DeprecatedRecentChanges extends React.Component {
-
-  static propTypes = {
-    t: PropTypes.func.isRequired, // i18next
-  };
-
-  constructor(props) {
-    super(props);
-    this.state = {
-      isRecentChangesSidebarSmall: false,
-      recentlyUpdatedPages: [],
-    };
-    this.reloadData = this.reloadData.bind(this);
-  }
-
-  componentWillMount() {
-    this.retrieveSizePreferenceFromLocalStorage();
-  }
-
-  async componentDidMount() {
-    this.reloadData();
-  }
-
-  async reloadData() {
-    try {
-      const { data } = await apiv3Get('/pages/recent');
-      this.setState({ recentlyUpdatedPages: data.pages });
-    }
-    catch (error) {
-      logger.error('failed to save', error);
-      toastError(error, 'Error occurred in updating History');
-    }
-  }
-
-  retrieveSizePreferenceFromLocalStorage() {
-    if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
-      this.setState({
-        isRecentChangesSidebarSmall: true,
-      });
-    }
-  }
-
-  changeSizeHandler = (e) => {
-    this.setState({
-      isRecentChangesSidebarSmall: e.target.checked,
-    });
-    window.localStorage.setItem('isRecentChangesSidebarSmall', e.target.checked);
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <>
-        <div className="grw-sidebar-content-header p-3 d-flex">
-          <h3 className="mb-0">{t('Recent Changes')}</h3>
-          {/* <h3 className="mb-0">{t('Recent Created')}</h3> */} {/* TODO: impl switching */}
-          <button type="button" className="btn btn-sm ml-auto grw-btn-reload-rc" onClick={this.reloadData}>
-            <i className="icon icon-reload"></i>
-          </button>
-          <div className="grw-recent-changes-resize-button custom-control custom-switch ml-2">
-            <input
-              id="recentChangesResize"
-              className="custom-control-input"
-              type="checkbox"
-              checked={this.state.isRecentChangesSidebarSmall}
-              onChange={this.changeSizeHandler}
-            />
-            <label className="custom-control-label" htmlFor="recentChangesResize">
-            </label>
-          </div>
-        </div>
-        <div className="grw-sidebar-content-body grw-recent-changes p-3">
-          <ul className="list-group list-group-flush">
-            {this.state.recentlyUpdatedPages.map(page => (this.state.isRecentChangesSidebarSmall
-              ? <SmallPageItem key={page._id} page={page} />
-              : <LargePageItem key={page._id} page={page} />))}
-          </ul>
-        </div>
-      </>
-    );
-  }
-
-}
-
-
-export default withTranslation()(DeprecatedRecentChanges);
+export default RecentChanges;

+ 0 - 49
packages/app/src/components/Sidebar/SidebarContents.jsx

@@ -1,49 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import NavigationContainer from '~/client/services/NavigationContainer';
-
-import RecentChanges from './RecentChanges';
-import CustomSidebar from './CustomSidebar';
-
-const SidebarContents = (props) => {
-  const { navigationContainer, isSharedUser } = props;
-
-  if (isSharedUser) {
-    return null;
-  }
-
-  let Contents;
-  switch (navigationContainer.state.sidebarContentsId) {
-    case 'recent':
-      Contents = RecentChanges;
-      break;
-    default:
-      Contents = CustomSidebar;
-  }
-
-  return (
-    <Contents />
-  );
-
-};
-
-SidebarContents.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-
-  isSharedUser: PropTypes.bool,
-};
-
-SidebarContents.defaultProps = {
-  isSharedUser: false,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const SidebarContentsWrapper = withUnstatedContainers(SidebarContents, [NavigationContainer]);
-
-export default withTranslation()(SidebarContentsWrapper);

+ 31 - 0
packages/app/src/components/Sidebar/SidebarContents.tsx

@@ -0,0 +1,31 @@
+import React, { FC } from 'react';
+
+import { SidebarContentsType } from '~/interfaces/ui';
+import { useCurrentSidebarContents } from '~/stores/ui';
+
+import RecentChanges from './RecentChanges';
+import CustomSidebar from './CustomSidebar';
+
+type Props = {
+};
+
+const SidebarContents: FC<Props> = (props: Props) => {
+
+  const { data: currentSidebarContents } = useCurrentSidebarContents();
+
+  let Contents;
+  switch (currentSidebarContents) {
+    case SidebarContentsType.RECENT:
+      Contents = RecentChanges;
+      break;
+    default:
+      Contents = CustomSidebar;
+  }
+
+  return (
+    <Contents />
+  );
+
+};
+
+export default SidebarContents;

+ 0 - 94
packages/app/src/components/Sidebar/SidebarNav.jsx

@@ -1,94 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
-
-
-class SidebarNav extends React.Component {
-
-  static propTypes = {
-    onItemSelected: PropTypes.func,
-  };
-
-  state = {
-  };
-
-  itemSelectedHandler = (contentsId) => {
-    const { navigationContainer, onItemSelected } = this.props;
-    if (onItemSelected != null) {
-      onItemSelected(contentsId);
-    }
-
-    navigationContainer.selectSidebarContents(contentsId);
-  }
-
-  PrimaryItem = ({ id, label, iconName }) => {
-    const { sidebarContentsId } = this.props.navigationContainer.state;
-    const isSelected = sidebarContentsId === id;
-
-    return (
-      <button
-        type="button"
-        className={`d-block btn btn-primary ${isSelected ? 'active' : ''}`}
-        onClick={() => this.itemSelectedHandler(id)}
-      >
-        <i className="material-icons">{iconName}</i>
-      </button>
-    );
-  }
-
-  SecondaryItem({
-    label, iconName, href, isBlank,
-  }) {
-    return (
-      <a href={href} className="d-block btn btn-primary" target={`${isBlank ? '_blank' : ''}`}>
-        <i className="material-icons">{iconName}</i>
-      </a>
-    );
-  }
-
-  generateIconFactory(classNames) {
-    return () => <i className={classNames}></i>;
-  }
-
-  render() {
-    const { isAdmin, currentUsername, isSharedUser } = this.props.appContainer;
-    const isLoggedIn = currentUsername != null;
-
-    const { PrimaryItem, SecondaryItem } = this;
-
-    return (
-      <div className="grw-sidebar-nav">
-        <div className="grw-sidebar-nav-primary-container">
-          {!isSharedUser && <PrimaryItem id="custom" label="Custom Sidebar" iconName="code" />}
-          {!isSharedUser && <PrimaryItem id="recent" label="Recent Changes" iconName="update" />}
-          {/* <PrimaryItem id="tag" label="Tags" iconName="icon-tag" /> */}
-          {/* <PrimaryItem id="favorite" label="Favorite" iconName="icon-star" /> */}
-        </div>
-        <div className="grw-sidebar-nav-secondary-container">
-          {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
-          {isLoggedIn && <SecondaryItem label="Draft" iconName="file_copy" href="/me/drafts" />}
-          <SecondaryItem label="Help" iconName="help" href="https://docs.growi.org" isBlank />
-          <SecondaryItem label="Trash" iconName="delete" href="/trash" />
-        </div>
-      </div>
-    );
-  }
-
-}
-
-SidebarNav.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const SidebarNavWrapper = withUnstatedContainers(SidebarNav, [AppContainer, NavigationContainer]);
-
-export default withTranslation()(SidebarNavWrapper);

+ 96 - 0
packages/app/src/components/Sidebar/SidebarNav.tsx

@@ -0,0 +1,96 @@
+import React, { FC, memo, useCallback } from 'react';
+
+import { scheduleToPutUserUISettings } from '~/client/services/user-ui-settings';
+import { SidebarContentsType } from '~/interfaces/ui';
+import { useCurrentUser, useIsSharedUser } from '~/stores/context';
+import { useCurrentSidebarContents } from '~/stores/ui';
+
+
+type PrimaryItemProps = {
+  contents: SidebarContentsType,
+  label: string,
+  iconName: string,
+  onItemSelected: (contents: SidebarContentsType) => void,
+}
+
+const PrimaryItem: FC<PrimaryItemProps> = (props: PrimaryItemProps) => {
+  const {
+    contents, iconName, onItemSelected,
+  } = props;
+
+  const { data: currentContents, mutate } = useCurrentSidebarContents();
+
+  const isSelected = contents === currentContents;
+
+  const itemSelectedHandler = useCallback(() => {
+    if (onItemSelected != null) {
+      onItemSelected(contents);
+    }
+
+    mutate(contents, false);
+    scheduleToPutUserUISettings({ currentSidebarContents: contents });
+  }, [contents, mutate, onItemSelected]);
+
+  return (
+    <button
+      type="button"
+      className={`d-block btn btn-primary ${isSelected ? 'active' : ''}`}
+      onClick={itemSelectedHandler}
+    >
+      <i className="material-icons">{iconName}</i>
+    </button>
+  );
+};
+
+type SecondaryItemProps = {
+  label: string,
+  href: string,
+  iconName: string,
+  isBlank?: boolean,
+}
+
+const SecondaryItem: FC<SecondaryItemProps> = memo((props: SecondaryItemProps) => {
+  const { iconName, href, isBlank } = props;
+
+  return (
+    <a href={href} className="d-block btn btn-primary" target={`${isBlank ? '_blank' : ''}`}>
+      <i className="material-icons">{iconName}</i>
+    </a>
+  );
+});
+
+
+type Props = {
+  onItemSelected: (contents: SidebarContentsType) => void,
+}
+
+const SidebarNav: FC<Props> = (props: Props) => {
+
+  const { data: isSharedUser } = useIsSharedUser();
+  const { data: currentUser } = useCurrentUser();
+
+  const isAdmin = currentUser?.admin;
+  const isLoggedIn = currentUser != null;
+
+  const { onItemSelected } = props;
+
+  return (
+    <div className="grw-sidebar-nav">
+      <div className="grw-sidebar-nav-primary-container">
+        {!isSharedUser && <PrimaryItem contents={SidebarContentsType.CUSTOM} label="Custom Sidebar" iconName="code" onItemSelected={onItemSelected} />}
+        {!isSharedUser && <PrimaryItem contents={SidebarContentsType.RECENT} label="Recent Changes" iconName="update" onItemSelected={onItemSelected} />}
+        {/* <PrimaryItem id="tag" label="Tags" iconName="icon-tag" /> */}
+        {/* <PrimaryItem id="favorite" label="Favorite" iconName="icon-star" /> */}
+      </div>
+      <div className="grw-sidebar-nav-secondary-container">
+        {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
+        {isLoggedIn && <SecondaryItem label="Draft" iconName="file_copy" href="/me/drafts" />}
+        <SecondaryItem label="Help" iconName="help" href="https://docs.growi.org" isBlank />
+        <SecondaryItem label="Trash" iconName="delete" href="/trash" />
+      </div>
+    </div>
+  );
+
+};
+
+export default SidebarNav;

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

+ 9 - 16
packages/app/src/components/StickyStretchableScroller.jsx

@@ -5,9 +5,6 @@ import { debounce } from 'throttle-debounce';
 import StickyEvents from 'sticky-events';
 import loggerFactory from '~/utils/logger';
 
-import NavigationContainer from '~/client/services/NavigationContainer';
-import { withUnstatedContainers } from './UnstatedUtils';
-
 const logger = loggerFactory('growi:cli:StickyStretchableScroller');
 
 
@@ -49,9 +46,9 @@ const StickyStretchableScroller = (props) => {
 
   let { scrollTargetSelector } = props;
   const {
-    navigationContainer,
     children, contentsElemSelector, stickyElemSelector,
     calcViewHeightFunc, calcContentsHeightFunc,
+    resetKey,
   } = props;
 
   if (scrollTargetSelector == null && children == null) {
@@ -105,7 +102,7 @@ const StickyStretchableScroller = (props) => {
 
   const stickyChangeHandler = useCallback((event) => {
     logger.debug('StickyEvents.CHANGE detected');
-    resetScrollbar();
+    setTimeout(resetScrollbar, 100);
   }, [resetScrollbar]);
 
   // setup effect by sticky event
@@ -141,17 +138,12 @@ const StickyStretchableScroller = (props) => {
     };
   }, [resetScrollbarDebounced]);
 
-  // setup effect by isScrollTop
+  // setup effect on init
   useEffect(() => {
-    if (navigationContainer.state.isScrollTop) {
-      resetScrollbar();
+    if (resetKey != null) {
+      resetScrollbarDebounced();
     }
-  }, [navigationContainer.state.isScrollTop, resetScrollbar]);
-
-  // setup effect by update props
-  useEffect(() => {
-    resetScrollbarDebounced();
-  }, [resetScrollbarDebounced]);
+  }, [resetKey, resetScrollbarDebounced]);
 
   return (
     <>
@@ -161,15 +153,16 @@ const StickyStretchableScroller = (props) => {
 };
 
 StickyStretchableScroller.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   contentsElemSelector: PropTypes.string.isRequired,
 
   children: PropTypes.node,
   scrollTargetSelector: PropTypes.string,
   stickyElemSelector: PropTypes.string,
 
+  resetKey: PropTypes.any,
+
   calcViewHeightFunc: PropTypes.func,
   calcContentsHeightFunc: PropTypes.func,
 };
 
-export default withUnstatedContainers(StickyStretchableScroller, [NavigationContainer]);
+export default StickyStretchableScroller;

+ 6 - 6
packages/app/src/components/TableOfContents.jsx

@@ -5,7 +5,8 @@ import loggerFactory from '~/utils/logger';
 
 
 import PageContainer from '~/client/services/PageContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
+import { addSmoothScrollEvent } from '~/client/util/smooth-scroll';
+import { blinkElem } from '~/client/util/blink-section-header';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 
@@ -20,7 +21,7 @@ const logger = loggerFactory('growi:TableOfContents');
  */
 const TableOfContents = (props) => {
 
-  const { t, pageContainer, navigationContainer } = props;
+  const { t, pageContainer } = props;
   const { pageUser } = pageContainer.state;
   const isUserPage = pageUser != null;
 
@@ -50,8 +51,8 @@ const TableOfContents = (props) => {
   useEffect(() => {
     const tocDom = document.getElementById('revision-toc-content');
     const anchorsInToc = Array.from(tocDom.getElementsByTagName('a'));
-    navigationContainer.addSmoothScrollEvent(anchorsInToc);
-  }, [tocHtml, navigationContainer]);
+    addSmoothScrollEvent(anchorsInToc, blinkElem);
+  }, [tocHtml]);
 
   return (
     <StickyStretchableScroller
@@ -85,13 +86,12 @@ const TableOfContents = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const TableOfContentsWrapper = withUnstatedContainers(TableOfContents, [PageContainer, NavigationContainer]);
+const TableOfContentsWrapper = withUnstatedContainers(TableOfContents, [PageContainer]);
 
 TableOfContents.propTypes = {
   t: PropTypes.func.isRequired, // i18next
 
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 
 export default withTranslation()(TableOfContentsWrapper);

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

+ 3 - 0
packages/app/src/interfaces/has-object-id.ts

@@ -0,0 +1,3 @@
+export type HasObjectId = {
+  _id: string,
+};

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

+ 6 - 0
packages/app/src/interfaces/ui.ts

@@ -0,0 +1,6 @@
+export const SidebarContentsType = {
+  CUSTOM: 'custom',
+  RECENT: 'recent',
+} as const;
+export const AllSidebarContentsType = Object.values(SidebarContentsType);
+export type SidebarContentsType = typeof SidebarContentsType[keyof typeof SidebarContentsType];

+ 12 - 0
packages/app/src/interfaces/user-ui-settings.ts

@@ -0,0 +1,12 @@
+import { IUser } from './user';
+
+import { SidebarContentsType } from './ui';
+
+export interface IUserUISettings {
+  user: IUser | string;
+  isSidebarCollapsed: boolean,
+  currentSidebarContents: SidebarContentsType,
+  currentProductNavWidth: number,
+  preferDrawerModeByUser: boolean,
+  preferDrawerModeOnEditByUser: boolean,
+}

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

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

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

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

@@ -0,0 +1,28 @@
+import {
+  Schema, Model, Document,
+} from 'mongoose';
+
+import { getOrCreateModel } from '@growi/core';
+
+import { SidebarContentsType } from '~/interfaces/ui';
+import { IUserUISettings } from '~/interfaces/user-ui-settings';
+
+
+export interface UserUISettingsDocument extends IUserUISettings, Document {}
+export type UserUISettingsModel = Model<UserUISettingsDocument>
+
+const schema = new Schema<UserUISettingsDocument, UserUISettingsModel>({
+  user: { type: Schema.Types.ObjectId, ref: 'User', unique: true },
+  isSidebarCollapsed: { type: Boolean, default: false },
+  currentSidebarContents: {
+    type: String,
+    enum: SidebarContentsType,
+    default: SidebarContentsType.RECENT,
+  },
+  currentProductNavWidth: { type: Number },
+  preferDrawerModeByUser: { type: Boolean, default: false },
+  preferDrawerModeOnEditByUser: { type: Boolean, default: true },
+});
+
+
+export default getOrCreateModel<UserUISettingsDocument, UserUISettingsModel>('UserUISettings', schema);

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

+ 14 - 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,5 +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;
 };

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

@@ -169,9 +169,13 @@ module.exports = (crowi) => {
   const globalNotificationService = crowi.getGlobalNotificationService();
   const socketIoService = crowi.socketIoService;
   const { Page, GlobalNotificationSetting } = crowi.models;
-  const { exportService } = crowi;
+  const { pageService, exportService } = crowi;
 
   const validator = {
+    getPage: [
+      query('id').if(value => value != null).isMongoId(),
+      query('path').if(value => value != null).isString(),
+    ],
     likes: [
       body('pageId').isString(),
       body('bool').isBoolean(),
@@ -198,6 +202,70 @@ module.exports = (crowi) => {
     ],
   };
 
+  /**
+   * @swagger
+   *
+   *    /page:
+   *      get:
+   *        tags: [Page]
+   *        operationId: getPage
+   *        summary: /page
+   *        description: get page by pagePath or pageId
+   *        parameters:
+   *          - name: pageId
+   *            in: query
+   *            description: page id
+   *            schema:
+   *              $ref: '#/components/schemas/Page/properties/_id'
+   *          - name: path
+   *            in: query
+   *            description: page path
+   *            schema:
+   *              $ref: '#/components/schemas/Page/properties/path'
+   *        responses:
+   *          200:
+   *            description: Page data
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/Page'
+   */
+  router.get('/', accessTokenParser, loginRequired, validator.getPage, apiV3FormValidator, async(req, res) => {
+    const { pageId, path } = req.query;
+
+    if (pageId == null && path == null) {
+      return res.apiv3Err(new ErrorV3('Parameter pagePath or pageId is required.', 'invalid-request'));
+    }
+
+    let result = {};
+    try {
+      result = await pageService.findPageAndMetaDataByViewer({ pageId, path, user: req.user });
+    }
+    catch (err) {
+      logger.error('get-page-failed', err);
+      return res.apiv3Err(err, 500);
+    }
+
+    const page = result.page;
+
+    if (page == null) {
+      return res.apiv3(result);
+    }
+
+    try {
+      page.initLatestRevisionField();
+
+      // populate
+      result.page = await page.populateDataToShowRevision();
+    }
+    catch (err) {
+      logger.error('populate-page-failed', err);
+      return res.apiv3Err(err, 500);
+    }
+
+    return res.apiv3(result);
+  });
+
   /**
    * @swagger
    *

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

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

@@ -0,0 +1,68 @@
+import express from 'express';
+import { body } from 'express-validator';
+import { AllSidebarContentsType } from '~/interfaces/ui';
+
+import loggerFactory from '~/utils/logger';
+
+import UserUISettings from '../../models/user-ui-settings';
+import ErrorV3 from '../../models/vo/error-apiv3';
+
+const logger = loggerFactory('growi:routes:apiv3:user-ui-settings');
+
+const router = express.Router();
+
+module.exports = (crowi) => {
+  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const csrf = require('../../middlewares/csrf')(crowi);
+  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
+
+  const validatorForPut = [
+    body('settings').exists().withMessage('The body param \'settings\' is required'),
+    body('settings.isSidebarCollapsed').optional().isBoolean(),
+    body('settings.currentSidebarContents').optional().isIn(AllSidebarContentsType),
+    body('settings.currentProductNavWidth').optional().isNumeric(),
+    body('settings.preferDrawerModeByUser').optional().isBoolean(),
+    body('settings.preferDrawerModeOnEditByUser').optional().isBoolean(),
+  ];
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  router.put('/', loginRequiredStrictly, csrf, validatorForPut, apiV3FormValidator, async(req: any, res: any) => {
+    const { user } = req;
+    const { settings } = req.body;
+
+    // extract only necessary params
+    const updateData = {
+      isSidebarCollapsed: settings.isSidebarCollapsed,
+      currentSidebarContents: settings.currentSidebarContents,
+      currentProductNavWidth: settings.currentProductNavWidth,
+      preferDrawerModeByUser: settings.preferDrawerModeByUser,
+      preferDrawerModeOnEditByUser: settings.preferDrawerModeOnEditByUser,
+    };
+    // remove the keys that have null value
+    Object.keys(updateData).forEach((key) => {
+      if (updateData[key] == null) {
+        delete updateData[key];
+      }
+    });
+
+    try {
+      const updatedSettings = await UserUISettings.findOneAndUpdate(
+        { user: user._id },
+        {
+          $set: {
+            user: user._id,
+            ...updateData,
+          },
+        },
+        { upsert: true, new: true },
+      );
+      return res.apiv3(updatedSettings);
+    }
+    catch (err) {
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(err));
+    }
+  });
+
+  return router;
+};

+ 25 - 18
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);
@@ -156,7 +159,6 @@ module.exports = function(crowi, app) {
   app.get('/_api/users.list'          , accessTokenParser , loginRequired , user.api.list);
   app.get('/_api/pages.list'          , accessTokenParser , loginRequired , page.api.list);
   app.post('/_api/pages.update'       , accessTokenParser , loginRequiredStrictly , csrf, page.api.update);
-  app.get('/_api/pages.get'           , accessTokenParser , loginRequired , page.api.get);
   app.get('/_api/pages.exist'         , accessTokenParser , loginRequired , page.api.exist);
   app.get('/_api/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
   app.get('/_api/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
@@ -179,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);
@@ -194,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);
 
 };

+ 0 - 83
packages/app/src/server/routes/page.js

@@ -891,89 +891,6 @@ module.exports = function(crowi, app) {
     }
   };
 
-  /**
-   * @swagger
-   *
-   *    /pages.get:
-   *      get:
-   *        tags: [Pages, CrowiCompatibles]
-   *        operationId: getPage
-   *        summary: /pages.get
-   *        description: Get page data
-   *        parameters:
-   *          - in: query
-   *            name: page_id
-   *            schema:
-   *              $ref: '#/components/schemas/Page/properties/_id'
-   *          - in: query
-   *            name: path
-   *            schema:
-   *              $ref: '#/components/schemas/Page/properties/path'
-   *          - in: query
-   *            name: revision_id
-   *            schema:
-   *              $ref: '#/components/schemas/Revision/properties/_id'
-   *        responses:
-   *          200:
-   *            description: Succeeded to get page data.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    page:
-   *                      $ref: '#/components/schemas/Page'
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {get} /pages.get Get page data
-   * @apiName GetPage
-   * @apiGroup Page
-   *
-   * @apiParam {String} page_id
-   * @apiParam {String} path
-   * @apiParam {String} revision_id
-   */
-  api.get = async function(req, res) {
-    const pagePath = req.query.path || null;
-    const pageId = req.query.page_id || null; // TODO: handling
-
-    if (!pageId && !pagePath) {
-      return res.json(ApiResponse.error(new Error('Parameter path or page_id is required.')));
-    }
-
-    let page;
-    try {
-      if (pageId) { // prioritized
-        page = await Page.findByIdAndViewer(pageId, req.user);
-      }
-      else if (pagePath) {
-        page = await Page.findByPathAndViewer(pagePath, req.user);
-      }
-
-      if (page == null) {
-        throw new Error(`Page '${pageId || pagePath}' is not found or forbidden`, 'notfound_or_forbidden');
-      }
-
-      page.initLatestRevisionField();
-
-      // populate
-      page = await page.populateDataToShowRevision();
-    }
-    catch (err) {
-      return res.json(ApiResponse.error(err));
-    }
-
-    const result = {};
-    result.page = page; // TODO consider to use serializePageSecurely method -- 2018.08.06 Yuki Takei
-
-    return res.json(ApiResponse.success(result));
-  };
-
   /**
    * @swagger
    *

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

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

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

+ 38 - 1
packages/app/src/server/service/page.js

@@ -10,7 +10,7 @@ const debug = require('debug')('growi:models:page');
 const { Writable } = require('stream');
 const { createBatchStream } = require('~/server/util/batch-stream');
 
-const { isTrashPage } = pagePathUtils;
+const { isCreatablePage, isDeletablePage, isTrashPage } = pagePathUtils;
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 
 const BULK_REINDEX_SIZE = 100;
@@ -27,6 +27,43 @@ class PageService {
     this.pageEvent.on('createMany', this.pageEvent.onCreateMany);
   }
 
+  async findPageAndMetaDataByViewer({ pageId, path, user }) {
+
+    const Page = this.crowi.model('Page');
+
+    let page;
+    if (pageId != null) { // prioritized
+      page = await Page.findByIdAndViewer(pageId, user);
+    }
+    else {
+      page = await Page.findByPathAndViewer(path, user);
+    }
+
+    const result = {};
+
+    if (page == null) {
+      const isExist = await Page.count({ $or: [{ _id: pageId }, { path }] }) > 0;
+      result.isForbidden = isExist;
+      result.isNotFound = !isExist;
+      result.isCreatable = isCreatablePage(path);
+      result.isDeletable = false;
+      result.canDeleteCompletely = false;
+      result.page = page;
+
+      return result;
+    }
+
+    result.page = page;
+    result.isForbidden = false;
+    result.isNotFound = false;
+    result.isCreatable = false;
+    result.isDeletable = isDeletablePage(path);
+    result.isDeleted = page.isDeleted();
+    result.canDeleteCompletely = user != null && user.canDeleteCompletely(page.creator);
+
+    return result;
+  }
+
   /**
    * go back by using redirectTo and return the paths
    *  ex: when

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

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