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

Resolve conflict merging master

Taichi Masuyama 3 лет назад
Родитель
Сommit
0c4b922232
100 измененных файлов с 1422 добавлено и 1010 удалено
  1. 18 23
      .github/workflows/ci-app-prod.yml
  2. 2 2
      .github/workflows/reusable-app-prod.yml
  3. 16 1
      CHANGELOG.md
  4. 0 0
      packages/app/_obsolete/src/client/services/ContextExtractor.tsx
  5. 1 1
      packages/app/config/rate-limiter.ts
  6. 2 2
      packages/app/docker/README.md
  7. 5 1
      packages/app/public/static/locales/en_US/admin.json
  8. 7 12
      packages/app/public/static/locales/en_US/translation.json
  9. 7 3
      packages/app/public/static/locales/ja_JP/admin.json
  10. 7 12
      packages/app/public/static/locales/ja_JP/translation.json
  11. 5 1
      packages/app/public/static/locales/zh_CN/admin.json
  12. 7 12
      packages/app/public/static/locales/zh_CN/translation.json
  13. 1 1
      packages/app/src/components/Admin/Customize/Customize.jsx
  14. 4 5
      packages/app/src/components/Admin/Customize/CustomizeLogoSetting.tsx
  15. 0 96
      packages/app/src/components/Admin/Customize/CustomizeTitle.jsx
  16. 79 0
      packages/app/src/components/Admin/Customize/CustomizeTitle.tsx
  17. 6 1
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  18. 54 13
      packages/app/src/components/Common/ImageCropModal.tsx
  19. 10 4
      packages/app/src/components/ContentLinkButtons.tsx
  20. 72 12
      packages/app/src/components/InvitedForm.tsx
  21. 4 3
      packages/app/src/components/Layout/AdminLayout.tsx
  22. 2 0
      packages/app/src/components/Layout/BasicLayout.tsx
  23. 1 1
      packages/app/src/components/Layout/SearchResultLayout.tsx
  24. 76 23
      packages/app/src/components/LoginForm.tsx
  25. 3 4
      packages/app/src/components/Me/ProfileImageSettings.tsx
  26. 16 14
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  27. 43 35
      packages/app/src/components/Page.tsx
  28. 6 4
      packages/app/src/components/Page/DisplaySwitcher.tsx
  29. 0 138
      packages/app/src/components/Page/RevisionLoader.jsx
  30. 118 0
      packages/app/src/components/Page/RevisionLoader.tsx
  31. 1 1
      packages/app/src/components/Page/TagLabels.tsx
  32. 0 6
      packages/app/src/components/PageComment/CommentEditor.module.scss
  33. 20 15
      packages/app/src/components/PageComment/CommentEditor.tsx
  34. 7 2
      packages/app/src/components/PageCreateModal.jsx
  35. 6 4
      packages/app/src/components/PageEditor.tsx
  36. 120 45
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  37. 94 0
      packages/app/src/components/PageEditor/DrawioCommunicationHelper.ts
  38. 0 172
      packages/app/src/components/PageEditor/DrawioModal.jsx
  39. 131 0
      packages/app/src/components/PageEditor/DrawioModal.tsx
  40. 9 3
      packages/app/src/components/PageEditor/Editor.tsx
  41. 9 13
      packages/app/src/components/PageEditor/EmojiPicker.tsx
  42. 28 39
      packages/app/src/components/PageEditor/EmojiPickerHelper.ts
  43. 80 48
      packages/app/src/components/PageEditorByHackmd.tsx
  44. 1 0
      packages/app/src/components/PageList/PageListItemL.tsx
  45. 1 1
      packages/app/src/components/PageTimeline.tsx
  46. 1 1
      packages/app/src/components/PrivateLegacyPages.tsx
  47. 2 4
      packages/app/src/components/ReactMarkdownComponents/CodeBlock.module.scss
  48. 3 9
      packages/app/src/components/SavePageControls.tsx
  49. 3 1
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  50. 1 1
      packages/app/src/components/Sidebar/RecentChanges.tsx
  51. 16 12
      packages/app/src/components/User/UserInfo.tsx
  52. 1 1
      packages/app/src/interfaces/activity.ts
  53. 5 0
      packages/app/src/interfaces/errors/login-error.ts
  54. 3 0
      packages/app/src/interfaces/errors/v3-error.ts
  55. 0 7
      packages/app/src/interfaces/services/renderer.ts
  56. 19 21
      packages/app/src/pages/[[...path]].page.tsx
  57. 17 0
      packages/app/src/pages/_error.page.tsx
  58. 8 3
      packages/app/src/pages/admin/[[...path]].page.tsx
  59. 10 2
      packages/app/src/pages/login.page.tsx
  60. 3 1
      packages/app/src/pages/me/[[...path]].page.tsx
  61. 41 6
      packages/app/src/pages/share/[[...path]].page.tsx
  62. 2 1
      packages/app/src/pages/tags.page.tsx
  63. 2 1
      packages/app/src/pages/trash.page.tsx
  64. 1 1
      packages/app/src/pages/utils/commons.ts
  65. 2 2
      packages/app/src/server/middlewares/apiv3-form-validator.ts
  66. 45 0
      packages/app/src/server/middlewares/invited-form-validator.ts
  67. 0 43
      packages/app/src/server/middlewares/login-form-validator.ts
  68. 7 12
      packages/app/src/server/models/vo/error-v3.js
  69. 2 3
      packages/app/src/server/routes/apiv3/app-settings.js
  70. 2 2
      packages/app/src/server/routes/apiv3/attachment.js
  71. 2 2
      packages/app/src/server/routes/apiv3/customize-setting.js
  72. 1 1
      packages/app/src/server/routes/apiv3/forgot-password.js
  73. 2 1
      packages/app/src/server/routes/apiv3/healthcheck.js
  74. 2 3
      packages/app/src/server/routes/apiv3/import.js
  75. 2 0
      packages/app/src/server/routes/apiv3/index.js
  76. 1 1
      packages/app/src/server/routes/apiv3/installer.ts
  77. 53 0
      packages/app/src/server/routes/apiv3/invited.ts
  78. 2 2
      packages/app/src/server/routes/apiv3/markdown-setting.js
  79. 2 2
      packages/app/src/server/routes/apiv3/notification-setting.js
  80. 1 1
      packages/app/src/server/routes/apiv3/page-listing.ts
  81. 3 2
      packages/app/src/server/routes/apiv3/page.js
  82. 2 2
      packages/app/src/server/routes/apiv3/pages.js
  83. 2 1
      packages/app/src/server/routes/apiv3/response.js
  84. 3 2
      packages/app/src/server/routes/apiv3/revisions.js
  85. 2 2
      packages/app/src/server/routes/apiv3/search.js
  86. 2 2
      packages/app/src/server/routes/apiv3/security-setting.js
  87. 2 2
      packages/app/src/server/routes/apiv3/share-links.js
  88. 2 2
      packages/app/src/server/routes/apiv3/slack-integration-legacy-settings.js
  89. 1 2
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  90. 1 2
      packages/app/src/server/routes/apiv3/slack-integration.js
  91. 2 1
      packages/app/src/server/routes/apiv3/user-activation.ts
  92. 2 1
      packages/app/src/server/routes/apiv3/user-group-relation.js
  93. 2 1
      packages/app/src/server/routes/apiv3/user-group.js
  94. 2 3
      packages/app/src/server/routes/apiv3/user-ui-settings.ts
  95. 2 1
      packages/app/src/server/routes/apiv3/users.js
  96. 2 2
      packages/app/src/server/routes/index.js
  97. 23 11
      packages/app/src/server/routes/login-passport.js
  98. 0 45
      packages/app/src/server/routes/login.js
  99. 15 1
      packages/app/src/server/service/installer.ts
  100. 9 5
      packages/app/src/stores/context.tsx

+ 18 - 23
.github/workflows/ci-app-prod.yml

@@ -3,8 +3,7 @@ name: Node CI for app production
 on:
   push:
     branches:
-      # - master
-      - support/apply-nextjs-2
+      - master
     paths:
       - .github/workflows/ci-app-prod.yml
       - .github/workflows/reusable-app-prod.yml
@@ -21,8 +20,7 @@ on:
       - packages/plugin-**
   pull_request:
     branches:
-      # - master
-      - support/apply-nextjs-2
+      - master
     types: [opened, reopened, synchronize]
     paths:
       - .github/workflows/ci-app-prod.yml
@@ -42,8 +40,7 @@ on:
 jobs:
 
   test-prod-node14:
-    # uses: weseek/growi/.github/workflows/reusable-app-prod.yml@support/master
-    uses: weseek/growi/.github/workflows/reusable-app-prod.yml@support/apply-nextjs-2
+    uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
     with:
       node-version: 14.x
       skip-cypress: true
@@ -52,30 +49,28 @@ jobs:
 
 
   test-prod-node16:
-    # uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
-    uses: weseek/growi/.github/workflows/reusable-app-prod.yml@support/apply-nextjs-2
+    uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
     with:
       node-version: 16.x
-      # skip-cypress: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) && contains( github.event.pull_request.labels.*.name, 'github_actions' ) }}
-      skip-cypress: true
+      skip-cypress: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) && contains( github.event.pull_request.labels.*.name, 'github_actions' ) }}
       cypress-report-artifact-name: Cypress report
     secrets:
       SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
-  # run-reg-suit-node16:
-  #   needs: [test-prod-node16]
+  run-reg-suit-node16:
+    needs: [test-prod-node16]
 
-  #   uses: weseek/growi/.github/workflows/reusable-app-reg-suit.yml@master
+    uses: weseek/growi/.github/workflows/reusable-app-reg-suit.yml@master
 
-  #   if: always()
+    if: always()
 
-  #   with:
-  #     node-version: 16.x
-  #     skip-reg-suit: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) && contains( github.event.pull_request.labels.*.name, 'github_actions' ) }}
-  #     cypress-report-artifact-name: Cypress report
-  #   secrets:
-  #     REG_NOTIFY_GITHUB_PLUGIN_CLIENTID: ${{ secrets.REG_NOTIFY_GITHUB_PLUGIN_CLIENTID }}
-  #     AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
-  #     AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
-  #     SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+    with:
+      node-version: 16.x
+      skip-reg-suit: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) && contains( github.event.pull_request.labels.*.name, 'github_actions' ) }}
+      cypress-report-artifact-name: Cypress report
+    secrets:
+      REG_NOTIFY_GITHUB_PLUGIN_CLIENTID: ${{ secrets.REG_NOTIFY_GITHUB_PLUGIN_CLIENTID }}
+      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
+      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 2 - 2
.github/workflows/reusable-app-prod.yml

@@ -215,7 +215,7 @@ jobs:
 
     - uses: actions/setup-node@v3
       with:
-        node-version: ${{ matrix.node-version }}
+        node-version: ${{ inputs.node-version }}
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 
@@ -237,7 +237,7 @@ jobs:
     - name: Download production files artifact
       uses: actions/download-artifact@v3
       with:
-        name: Production Files
+        name: Production Files (node${{ inputs.node-version }})
 
     - name: Extract procution files artifact
       run: |

+ 16 - 1
CHANGELOG.md

@@ -1,9 +1,24 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.1.4...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.1.5...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v5.1.5](https://github.com/weseek/growi/compare/v5.1.4...v5.1.5) - 2022-10-04
+
+### 💎 Features
+
+- feat: Add option to not use crop modal on brand logo upload (#6677) @Yohei-Shiina
+
+### 🚀 Improvement
+
+- imprv: Emoji picker performance for v5.x (#6689) @hakumizuki
+
+### 🐛 Bug Fixes
+
+- fix(auditlog): Attachment download is displayed even if the filter is unchecked (#6688) @miya
+- fix: firstName and lastName japanese translations in SAML  (#6631) @kaoritokashiki
+
 ## [v5.1.4](https://github.com/weseek/growi/compare/v5.1.3...v5.1.4) - 2022-09-12
 
 ### 💎 Features

+ 0 - 0
packages/app/src/client/services/ContextExtractor.tsx → packages/app/_obsolete/src/client/services/ContextExtractor.tsx


+ 1 - 1
packages/app/config/rate-limiter.ts

@@ -33,7 +33,7 @@ export const defaultConfig: IApiRateLimitEndpointMap = {
     maxRequests: MAX_REQUESTS_TIER_1,
     usersPerIpProspection: 100,
   },
-  '/invited/activateInvited': {
+  '/invited': {
     method: 'POST',
     maxRequests: MAX_REQUESTS_TIER_2,
   },

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

@@ -11,8 +11,8 @@ Supported tags and respective Dockerfile links
 ------------------------------------------------
 
 * [`6.0.0`, `6.0`, `6`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v6.0.0/packages/app/docker/Dockerfile)
-* [`5.1.4`, `5.1`, `5`](https://github.com/weseek/growi/blob/v5.1.4/packages/app/docker/Dockerfile)
-* [`5.1.4-nocdn`, `5.1-nocdn`, `5-nocdn`](https://github.com/weseek/growi/blob/v5.1.4/packages/app/docker/Dockerfile)
+* [`5.1.5`, `5.1`, `5`](https://github.com/weseek/growi/blob/v5.1.5/packages/app/docker/Dockerfile)
+* [`5.1.5-nocdn`, `5.1-nocdn`, `5-nocdn`](https://github.com/weseek/growi/blob/v5.1.5/packages/app/docker/Dockerfile)
 * [`4.5.23`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)
 * [`4.5.23-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)
 

+ 5 - 1
packages/app/public/static/locales/en_US/admin.json

@@ -907,6 +907,10 @@
     "PAGE_DELETE_COMPLETELY": "Delete completely page",
     "PAGE_REVERT": "Revert page",
     "PAGE_EMPTY_TRASH": "Empty trash",
+    "PAGE_RECURSIVELY_RENAME": "Recursive page rename",
+    "PAGE_RECURSIVELY_DELETE": "Recursive page delete",
+    "PAGE_RECURSIVELY_DELETE_COMPLETELY": "Recursive page delete completely",
+    "PAGE_RECURSIVELY_REVERT": "Recursive page revert",
     "PAGE_SUBSCRIBE": "Subscribe page",
     "PAGE_UNSUBSCRIBE": "Unsubscribe page",
     "PAGE_EXPORT": "Export page",
@@ -924,7 +928,7 @@
     "SHARE_LINK_NOT_FOUND": "Page view (Not found share link)",
     "ATTACHMENT_ADD": "Add Attachment",
     "ATTACHMENT_REMOVE": "Delete Attachment",
-    "ACTION_ATTACHMENT_DOWNLOAD": "Download Attachment",
+    "ATTACHMENT_DOWNLOAD": "Download Attachment",
     "SEARCH_PAGE": "Page Search",
     "SEARCH_PAGE_VIEW": "Page view(Search results page)",
     "ADMIN_APP_SETTING_UPDATE": "Update App Settings",

+ 7 - 12
packages/app/public/static/locales/en_US/translation.json

@@ -689,7 +689,9 @@
   "login": {
     "Sign in error": "Login error",
     "Registration successful": "Registration successful",
-    "Setup": "Setup"
+    "Setup": "Setup",
+    "enabled_ldap_has_configuration_problem":"LDAP is enabled but the configuration has something wrong.",
+    "set_env_var_for_logs": "(Please set the environment variables <code>DEBUG=crowi:service:PassportService</code> to get the logs)"
   },
   "invited": {
     "discription_heading": "Create Account",
@@ -706,16 +708,7 @@
     "fail_to_save_access_token": "Failed to save access_token. Please try again.",
     "fail_to_fetch_access_token": "Failed to fetch access_token. Please do connect again.",
     "successfully_disconnected": "Successfully Disconnected!",
-    "strategy_has_not_been_set_up": {
-      "LocalStrategy"  : "LocalStrategy has not been set up",
-      "LdapStrategy"   : "LdapStrategy has not been set up",
-      "GoogleStrategy" : "GoogleStrategy has not been set up",
-      "GitHubStrategy" : "GitHubStrategy has not been set up",
-      "TwitterStrategy": "TwitterStrategy has not been set up",
-      "OidcStrategy"   : "OidcStrategy has not been set up",
-      "SamlStrategy"   : "SamlStrategy has not been set up",
-      "Basic"          : "Basic has not been set up"
-    },
+    "strategy_has_not_been_set_up": "{{strategy}} has not been set up",
     "ldap_user_not_valid": "Ldap user is no valid",
     "external_account_not_exist": "Failed to find or create External account",
     "maximum_number_of_users": "Can not register more than the maximum number of users.",
@@ -749,7 +742,8 @@
     "Password field is required": "Password field is required.",
     "Username or E-mail has invalid characters": "Username or E-mail has invalid characters.",
     "Password minimum character should be more than 6 characters": "Password minimum character should be more than 6 characters.",
-    "user_not_found": "User not found."
+    "user_not_found": "User not found.",
+    "provider_duplicated_username_exception": "<p><strong><i class='icon-fw icon-ban'></i>DuplicatedUsernameException occured</strong></p><p class='mb-0'> Your {{ failedProviderForDuplicatedUsernameException }} authentication was succeeded, but a new user could not be created. See the issue <a href='https://github.com/weseek/growi/issues/193'>#193</a>.</p>"
   },
   "grid_edit":{
     "create_bootstrap_4_grid":"Create Bootstrap 4 Grid",
@@ -836,6 +830,7 @@
   "crop_image_modal": {
     "image_crop": "Image Crop",
     "crop": "Crop",
+    "save": "Save",
     "reset": "Reset",
     "cancel": "Cancel"
   },

+ 7 - 3
packages/app/public/static/locales/ja_JP/admin.json

@@ -225,8 +225,8 @@
       "attrMapId": "ID",
       "attrMapUsername": "ユーザー名",
       "attrMapMail": "メールアドレス",
-      "attrMapFirstName": "",
-      "attrMapLastName": "",
+      "attrMapFirstName": "",
+      "attrMapLastName": "",
       "ABLCRule": "ルール"
     }
   },
@@ -919,6 +919,10 @@
     "PAGE_DELETE_COMPLETELY": "ページの完全削除",
     "PAGE_REVERT": "ページを元に戻す",
     "PAGE_EMPTY_TRASH": "ゴミ箱を空にする",
+    "PAGE_RECURSIVELY_RENAME": "再帰的なページのリネーム",
+    "PAGE_RECURSIVELY_DELETE": "再帰的なページの削除",
+    "PAGE_RECURSIVELY_DELETE_COMPLETELY": "再起的なページの完全削除",
+    "PAGE_RECURSIVELY_REVERT": "再起的なページの復元",
     "PAGE_SUBSCRIBE": "ページをサブスクライブ",
     "PAGE_UNSUBSCRIBE": "ページをアンサブスクライブ",
     "PAGE_EXPORT": "マークダウン形式でページをエクスポート",
@@ -936,7 +940,7 @@
     "SHARE_LINK_NOT_FOUND": "ページ閲覧(存在しない共有リンク)",
     "ATTACHMENT_ADD": "添付データの追加",
     "ATTACHMENT_REMOVE": "添付データの削除",
-    "ACTION_ATTACHMENT_DOWNLOAD": "添付データのダウンロード",
+    "ATTACHMENT_DOWNLOAD": "添付データのダウンロード",
     "SEARCH_PAGE": "ページの検索",
     "SEARCH_PAGE_VIEW": "ページ閲覧(検索結果ページ)",
     "ADMIN_APP_SETTING_UPDATE": "アプリ設定の更新",

+ 7 - 12
packages/app/public/static/locales/ja_JP/translation.json

@@ -680,7 +680,9 @@
   "login": {
     "Sign in error": "ログインエラー",
     "Registration successful": "登録完了",
-    "Setup": "セットアップ"
+    "Setup": "セットアップ",
+    "enabled_ldap_has_configuration_problem":"LDAPは有効ですが、設定に問題があります。",
+    "set_env_var_for_logs": "(ログを取得するためには、環境変数 <code>DEBUG=crowi:service:PassportService</code> を設定してください。)"
   },
   "invited": {
     "discription_heading": "アカウント作成",
@@ -697,16 +699,7 @@
     "fail_to_save_access_token": "アクセストークンの保存に失敗しました、再度お試しください。",
     "fail_to_fetch_access_token": "アクセストークンの取得に失敗しました、再度お試しください。",
     "successfully_disconnected": "切断に成功しました!",
-    "strategy_has_not_been_set_up": {
-      "LocalStrategy"  : "LocalStrategy はセットアップされていません。",
-      "LdapStrategy"   : "LdapStrategy はセットアップされていません。",
-      "GoogleStrategy" : "GoogleStrategy はセットアップされていません。",
-      "GitHubStrategy" : "GitHubStrategy はセットアップされていません。",
-      "TwitterStrategy": "TwitterStrategy はセットアップされていません。",
-      "OidcStrategy"   : "OidcStrategy はセットアップされていません。",
-      "SamlStrategy"   : "SamlStrategy はセットアップされていません。",
-      "Basic"          : "Basic はセットアップされていません。"
-    },
+    "strategy_has_not_been_set_up": "{{strategy}} はセットアップされていません。",
     "ldap_user_not_valid": "Ldap user is no valid",
     "external_account_not_exist": "外部アカウントが見つからない、または作成に失敗しました",
     "maximum_number_of_users": "ユーザー数が上限を超えたためアクティベートできません。",
@@ -740,7 +733,8 @@
     "Password field is required": "パスワードの欄は必ず入力してください",
     "Username or E-mail has invalid characters": "ユーザー名または、メールアドレスに無効な文字があります",
     "Password minimum character should be more than 6 characters": "パスワードの最小文字数は6文字以上です",
-    "user_not_found": "ユーザーが見つかりません"
+    "user_not_found": "ユーザーが見つかりません",
+    "provider_duplicated_username_exception": "<p><strong><i class='icon-fw icon-ban'></i>エラー: DuplicatedUsernameException</strong></p><p class='mb-0'> {{ failedProviderForDuplicatedUsernameException }} 認証は成功しましたが、新しいユーザーを作成できませんでした。詳しくは<a href='https://github.com/weseek/growi/issues/193'>こちら: #193</a>.</p>"
   },
   "grid_edit":{
     "create_bootstrap_4_grid":"Bootstrap 4 グリッドを作成",
@@ -827,6 +821,7 @@
   "crop_image_modal": {
     "image_crop": "画像の切り抜き",
     "crop": "トリミング",
+    "save": "保存",
     "reset": "リセット",
     "cancel": "キャンセル"
   },

+ 5 - 1
packages/app/public/static/locales/zh_CN/admin.json

@@ -885,6 +885,10 @@
     "PAGE_DELETE_COMPLETELY": "彻底删除页面",
     "PAGE_REVERT": "还原页面",
     "PAGE_EMPTY_TRASH": "清空垃圾箱",
+    "PAGE_RECURSIVELY_RENAME": "递归页面重命名",
+    "PAGE_RECURSIVELY_DELETE": "递归页面删除",
+    "PAGE_RECURSIVELY_DELETE_COMPLETELY": "递归页面完全删除",
+    "PAGE_RECURSIVELY_REVERT": "递归页面还原",
     "PAGE_SUBSCRIBE": "订阅页面",
     "PAGE_UNSUBSCRIBE": "退订页面",
     "PAGE_EXPORT": "导出页面",
@@ -902,7 +906,7 @@
     "SHARE_LINK_NOT_FOUND": "页面浏览量(未找到分享链接)",
     "ATTACHMENT_ADD": "添加附件",
     "ATTACHMENT_REMOVE": "删除附件",
-    "ACTION_ATTACHMENT_DOWNLOAD": "下载附件",
+    "ATTACHMENT_DOWNLOAD": "下载附件",
     "SEARCH_PAGE": "页面搜索",
     "SEARCH_PAGE_VIEW": "页面浏览量(搜索结果页面)",
     "ADMIN_APP_SETTING_UPDATE": "更新应用设置",

+ 7 - 12
packages/app/public/static/locales/zh_CN/translation.json

@@ -736,7 +736,9 @@
 	"login": {
 		"Sign in error": "登录错误",
 		"Registration successful": "注册成功",
-		"Setup": "安装程序"
+		"Setup": "安装程序",
+    "enabled_ldap_has_configuration_problem":"启用了LDAP,但配置有问题。",
+    "set_env_var_for_logs": "(请设置环境变量 <code>DEBUG=crowi:service:PassportService</code> 以获得日志。)"
 	},
   "invited": {
     "discription_heading": "创建账户",
@@ -753,16 +755,7 @@
 		"fail_to_save_access_token": "无法保存访问令牌。请再试一次。",
 		"fail_to_fetch_access_token": "无法获取访问令牌。请重新连接。",
 		"successfully_disconnected": "成功断开连接!",
-    "strategy_has_not_been_set_up": {
-      "LocalStrategy"  : "LocalStrategy 尚未设置",
-      "LdapStrategy"   : "LdapStrategy 尚未设置",
-      "GoogleStrategy" : "GoogleStrategy 尚未设置",
-      "GitHubStrategy" : "GitHubStrategy 尚未设置",
-      "TwitterStrategy": "TwitterStrategy 尚未设置",
-      "OidcStrategy"   : "OidcStrategy 尚未设置",
-      "SamlStrategy"   : "SamlStrategy 尚未设置",
-      "Basic"          : "Basic 尚未设置"
-     },
+    "strategy_has_not_been_set_up": "{{strategy}} 尚未设置",
     "ldap_user_not_valid": "Ldap user is no valid",
     "external_account_not_exist": "查找或创建外部账户失败",
 		"maximum_number_of_users": "注册的用户数不能超过最大值。",
@@ -796,7 +789,8 @@
     "Password field is required": "密码字段是必需的",
     "Username or E-mail has invalid characters": "用户名或电子邮件有无效的字符",
     "Password minimum character should be more than 6 characters": "密码最小字符应超过6个字符",
-    "user_not_found": "未找到用户"
+    "user_not_found": "未找到用户",
+    "provider_duplicated_username_exception": "<p><strong><i class='icon-fw icon-ban'></i>发生了重复用户名异常</strong></p><p class='mb-0'> 你的 {{ failedProviderForDuplicatedUsernameException }} 认证成功了,但不能创建新的用户。参见问题<a href='https://github.com/weseek/growi/issues/193'>#193</a>.</p>"
 	},
   "grid_edit":{
     "create_bootstrap_4_grid":"创建Bootstrap 4网格",
@@ -883,6 +877,7 @@
   "crop_image_modal": {
     "image_crop": "图像裁剪",
     "crop": "修剪",
+    "save": "节省",
     "reset": "重启",
     "cancel": "取消"
   },

+ 1 - 1
packages/app/src/components/Admin/Customize/Customize.jsx

@@ -19,7 +19,7 @@ import CustomizeLogoSetting from './CustomizeLogoSetting';
 import CustomizeScriptSetting from './CustomizeScriptSetting';
 import CustomizeSidebarSetting from './CustomizeSidebarSetting';
 import CustomizeThemeSetting from './CustomizeThemeSetting';
-import CustomizeTitle from './CustomizeTitle';
+import { CustomizeTitle } from './CustomizeTitle';
 
 const logger = loggerFactory('growi:services:AdminCustomizePage');
 

+ 4 - 5
packages/app/src/components/Admin/Customize/CustomizeLogoSetting.tsx

@@ -66,7 +66,6 @@ const CustomizeLogoSetting = (): JSX.Element => {
     }
   }, [t, isDefaultLogo, customizedLogoSrc]);
 
-
   const onClickDeleteBtn = useCallback(async() => {
     try {
       await apiv3Delete('/customize-setting/delete-brand-logo');
@@ -80,7 +79,8 @@ const CustomizeLogoSetting = (): JSX.Element => {
     }
   }, [t]);
 
-  const onCropCompleted = useCallback(async(croppedImage) => {
+
+  const processImageCompletedHandler = useCallback(async(croppedImage) => {
     try {
       const formData = new FormData();
       formData.append('file', croppedImage);
@@ -93,10 +93,8 @@ const CustomizeLogoSetting = (): JSX.Element => {
       setRetrieveError(err);
       throw new Error('Failed to upload brand logo');
     }
-    setIsImageCropModalShow(false);
   }, [t]);
 
-
   return (
     <React.Fragment>
       <div className="row">
@@ -172,8 +170,9 @@ const CustomizeLogoSetting = (): JSX.Element => {
         isShow={isImageCropModalShow}
         src={uploadLogoSrc}
         onModalClose={() => setIsImageCropModalShow(false)}
-        onCropCompleted={onCropCompleted}
+        onImageProcessCompleted={processImageCompletedHandler}
         isCircular={false}
+        showCropOption={false}
       />
     </React.Fragment>
   );

+ 0 - 96
packages/app/src/components/Admin/Customize/CustomizeTitle.jsx

@@ -1,96 +0,0 @@
-/* eslint-disable max-len */
-import React from 'react';
-
-import { withTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import { Card, CardBody } from 'reactstrap';
-
-import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
-
-class CustomizeTitle extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  async onClickSubmit() {
-    const { t, adminCustomizeContainer } = this.props;
-
-    try {
-      await adminCustomizeContainer.updateCustomizeTitle();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.custom_title') }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { t, adminCustomizeContainer } = this.props;
-    const { currentCustomizeTitle } = adminCustomizeContainer.state;
-
-    return (
-      <React.Fragment>
-        <div className="row">
-          <div className="col-12">
-            <h2 className="admin-setting-header">{t('admin:customize_setting.custom_title')}</h2>
-          </div>
-
-          <div className="col-12">
-            <Card className="card well">
-              <CardBody className="px-0 py-2">
-                {/* eslint-disable react/no-danger */}
-                <p dangerouslySetInnerHTML={{ __html: t('admin:customize_setting.custom_title_detail') }} />
-                <ul>
-                  <li>
-                    <span dangerouslySetInnerHTML={{ __html: t('admin:customize_setting.custom_title_detail_placeholder1') }} />
-                  </li>
-                  <li>
-                    <span dangerouslySetInnerHTML={{ __html: t('admin:customize_setting.custom_title_detail_placeholder2') }} />
-                  </li>
-                  <li>
-                    <span dangerouslySetInnerHTML={{ __html: t('admin:customize_setting.custom_title_detail_placeholder3') }} />
-                  </li>
-                </ul>
-                {/* eslint-enable react/no-danger */}
-              </CardBody>
-            </Card>
-          </div>
-
-          {/* TODO i18n */}
-          <div className="form-text text-muted col-12">
-            Default Value: <code>&#123;&#123;pagename&#125;&#125; - &#123;&#123;sitename&#125;&#125;</code>
-            <br />
-            Default Output Example: <code className="xml">&lt;title&gt;Page name - My GROWI&lt;&#047;title&gt;</code>
-          </div>
-          <div className="form-group col-12">
-            <input
-              className="form-control"
-              defaultValue={currentCustomizeTitle}
-              onChange={(e) => { adminCustomizeContainer.changeCustomizeTitle(e.target.value) }}
-            />
-          </div>
-          <div className="col-12">
-            <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
-          </div>
-        </div>
-      </React.Fragment>
-    );
-  }
-
-}
-
-const CustomizeTitleWrapper = withUnstatedContainers(CustomizeTitle, [AdminCustomizeContainer]);
-
-CustomizeTitle.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
-};
-
-export default withTranslation()(CustomizeTitleWrapper);

+ 79 - 0
packages/app/src/components/Admin/Customize/CustomizeTitle.tsx

@@ -0,0 +1,79 @@
+import React, { FC, useState } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import { Card, CardBody } from 'reactstrap';
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { useCustomizeTitle } from '~/stores/context';
+
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+export const CustomizeTitle: FC = () => {
+
+  const { t } = useTranslation('admin');
+
+  const { data: customizeTitle } = useCustomizeTitle();
+
+  const [currentCustomizeTitle, setCrrentCustomizeTitle] = useState(customizeTitle);
+
+  const onClickSubmit = async() => {
+    try {
+      await apiv3Put('/customize-setting/customize-title', {
+        customizeTitle: currentCustomizeTitle,
+      });
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.custom_title') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
+  return (
+    <React.Fragment>
+      <div className="row">
+        <div className="col-12">
+          <h2 className="admin-setting-header">{t('admin:customize_settings.custom_title')}</h2>
+        </div>
+
+        <div className="col-12">
+          <Card className="card well">
+            <CardBody className="px-0 py-2">
+              {/* eslint-disable react/no-danger */}
+              <p dangerouslySetInnerHTML={{ __html: t('admin:customize_settings.custom_title_detail') }} />
+              <ul>
+                <li>
+                  <span dangerouslySetInnerHTML={{ __html: t('admin:customize_settings.custom_title_detail_placeholder1') }} />
+                </li>
+                <li>
+                  <span dangerouslySetInnerHTML={{ __html: t('admin:customize_settings.custom_title_detail_placeholder2') }} />
+                </li>
+                <li>
+                  <span dangerouslySetInnerHTML={{ __html: t('admin:customize_settings.custom_title_detail_placeholder3') }} />
+                </li>
+              </ul>
+              {/* eslint-enable react/no-danger */}
+            </CardBody>
+          </Card>
+        </div>
+
+        {/* TODO i18n */}
+        <div className="form-text text-muted col-12">
+            Default Value: <code>&#123;&#123;pagename&#125;&#125; - &#123;&#123;sitename&#125;&#125;</code>
+          <br />
+            Default Output Example: <code className="xml">&lt;title&gt;Page name - My GROWI&lt;&#047;title&gt;</code>
+        </div>
+        <div className="form-group col-12">
+          <input
+            className="form-control"
+            defaultValue={currentCustomizeTitle}
+            onChange={(e) => { setCrrentCustomizeTitle(e.target.value) }}
+          />
+        </div>
+        <div className="col-12">
+          <AdminUpdateButtonRow onClick={onClickSubmit} disabled={false} />
+        </div>
+      </div>
+    </React.Fragment>
+  );
+};

+ 6 - 1
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -245,7 +245,12 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
   }
 
   return (
-    <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: undefined } }} right={alignRight}>
+    <DropdownMenu
+      data-testid="page-item-control-menu"
+      positionFixed
+      modifiers={{ preventOverflow: { boundariesElement: undefined } }}
+      right={alignRight}
+    >
       {contents}
     </DropdownMenu>
   );

+ 54 - 13
packages/app/src/components/Common/ImageCropModal.tsx

@@ -33,17 +33,19 @@ type Props = {
   isShow: boolean,
   src: string | ArrayBuffer | null,
   onModalClose: () => void,
-  onCropCompleted: (res: any) => void,
+  onImageProcessCompleted: (res: any) => void,
   isCircular: boolean,
+  showCropOption: boolean
 }
 const ImageCropModal: FC<Props> = (props: Props) => {
 
   const {
-    isShow, src, onModalClose, onCropCompleted, isCircular,
+    isShow, src, onModalClose, onImageProcessCompleted, isCircular, showCropOption,
   } = props;
 
-  const [imageRef, setImageRef] = useState<HTMLImageElement>();
+  const [imageRef, setImageRef] = useState<HTMLImageElement | null>(null);
   const [cropOptions, setCropOtions] = useState<CropOptions>(null);
+  const [isCropImage, setIsCropImage] = useState<boolean>(true);
   const { t } = useTranslation();
   const reset = useCallback(() => {
     if (imageRef) {
@@ -93,31 +95,70 @@ const ImageCropModal: FC<Props> = (props: Props) => {
     }
   };
 
-  const crop = async() => {
-    // crop immages
+  // Convert base64 Image to blob
+  const convertBase64ToBlob = async(base64Image: string) => {
+    const base64Response = await fetch(base64Image);
+    return base64Response.blob();
+  };
+
+
+  // Clear image and set isImageCrop true on modal close
+  const onModalCloseHandler = async() => {
+    setImageRef(null);
+    setIsCropImage(true);
+    onModalClose();
+  };
+
+  // Process and save image
+  // Cropping image is optional
+  // If crop is active , the saved image is cropped image (png). Otherwise, the original image will be saved (Original size and file type)
+  const processAndSaveImage = async() => {
     if (imageRef && cropOptions?.width && cropOptions.height) {
-      const result = await getCroppedImg(imageRef, cropOptions);
-      onCropCompleted(result);
+      const processedImage = isCropImage ? await getCroppedImg(imageRef, cropOptions) : await convertBase64ToBlob(imageRef.src);
+      // Save image to database
+      onImageProcessCompleted(processedImage);
     }
+    onModalCloseHandler();
   };
 
   return (
-    <Modal isOpen={isShow} toggle={onModalClose}>
-      <ModalHeader tag="h4" toggle={onModalClose} className="bg-info text-light">
+    <Modal isOpen={isShow} toggle={onModalCloseHandler}>
+      <ModalHeader tag="h4" toggle={onModalCloseHandler} className="bg-info text-light">
         {t('crop_image_modal.image_crop')}
       </ModalHeader>
       <ModalBody className="my-4">
-        <ReactCrop src={src} crop={cropOptions} onImageLoaded={onImageLoaded} onChange={onCropChange} circularCrop={isCircular} />
+        {
+          isCropImage
+            ? (<ReactCrop src={src} crop={cropOptions} onImageLoaded={onImageLoaded} onChange={onCropChange} circularCrop={isCircular} />)
+            : (<img style={{ maxWidth: imageRef?.width }} src={imageRef?.src} />)
+        }
       </ModalBody>
       <ModalFooter>
         <button type="button" className="btn btn-outline-danger rounded-pill mr-auto" onClick={reset}>
           {t('crop_image_modal.reset')}
         </button>
-        <button type="button" className="btn btn-outline-secondary rounded-pill mr-2" onClick={onModalClose}>
+        { !showCropOption && (
+          <div className="mr-auto">
+            <div className="custom-control custom-switch ">
+              <input
+                id="cropImageOption"
+                className="custom-control-input mr-auto"
+                type="checkbox"
+                checked={isCropImage}
+                onChange={() => { setIsCropImage(!isCropImage) }}
+              />
+              <label className="custom-control-label" htmlFor="cropImageOption">
+                { t('crop_image_modal.image_crop') }
+              </label>
+            </div>
+          </div>
+        )
+        }
+        <button type="button" className="btn btn-outline-secondary rounded-pill mr-2" onClick={onModalCloseHandler}>
           {t('crop_image_modal.cancel')}
         </button>
-        <button type="button" className="btn btn-outline-primary rounded-pill" onClick={crop}>
-          {t('crop_image_modal.crop')}
+        <button type="button" className="btn btn-outline-primary rounded-pill" onClick={processAndSaveImage}>
+          { isCropImage ? t('crop_image_modal.crop') : t('crop_image_modal.save') }
         </button>
       </ModalFooter>
     </Modal>

+ 10 - 4
packages/app/src/components/ContentLinkButtons.tsx

@@ -1,8 +1,9 @@
 import React, { useCallback } from 'react';
 
+import { IUserHasId } from '@growi/core';
+
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { RecentlyCreatedIcon } from '~/components/Icons/RecentlyCreatedIcon';
-import { usePageUser } from '~/stores/context';
 
 import styles from './ContentLinkButtons.module.scss';
 
@@ -52,11 +53,16 @@ const RecentlyCreatedLinkButton = React.memo(() => {
 
 RecentlyCreatedLinkButton.displayName = 'RecentlyCreatedLinkButton';
 
-export const ContentLinkButtons = (): JSX.Element => {
 
-  const { data: pageUser } = usePageUser();
+export type ContentLinkButtonsProps = {
+  author?: IUserHasId,
+}
+
+export const ContentLinkButtons = (props: ContentLinkButtonsProps): JSX.Element => {
+
+  const { author } = props;
 
-  if (pageUser == null || pageUser.status === 4) {
+  if (author == null || author.status === 4) {
     return <></>;
   }
 

+ 72 - 12
packages/app/src/components/InvitedForm.tsx

@@ -1,8 +1,12 @@
-import React from 'react';
+import React, { useCallback, useState } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+
+import { useCurrentUser } from '../stores/context';
 
-import { useCsrfToken, useCurrentUser } from '../stores/context';
 
 export type InvitedFormProps = {
   invitedFormUsername: string,
@@ -10,23 +14,79 @@ export type InvitedFormProps = {
 }
 
 export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
+
   const { t } = useTranslation();
-  const { data: csrfToken } = useCsrfToken();
+  const router = useRouter();
   const { data: user } = useCurrentUser();
+  const [isConnectSuccess, setIsConnectSuccess] = useState<boolean>(false);
+  const [loginErrors, setLoginErrors] = useState<Error[]>([]);
 
   const { invitedFormUsername, invitedFormName } = props;
 
+  const submitHandler = useCallback(async(e) => {
+    e.preventDefault();
+
+    const formData = e.target.elements;
+
+    const {
+      'invitedForm[name]': { value: name },
+      'invitedForm[password]': { value: password },
+      'invitedForm[username]': { value: username },
+    } = formData;
+
+    const invitedForm = {
+      name,
+      password,
+      username,
+    };
+
+    try {
+      const res = await apiv3Post('/invited', { invitedForm });
+      setIsConnectSuccess(true);
+      const { redirectTo } = res.data;
+      router.push(redirectTo);
+    }
+    catch (err) {
+      setLoginErrors(err);
+    }
+  }, [router]);
+
+  const formNotification = useCallback(() => {
+
+    if (isConnectSuccess) {
+      return (
+        <p className="alert alert-success">
+          <strong>{ t('message.successfully_connected') }</strong><br></br>
+        </p>
+      );
+    }
+
+    return (
+      <>
+        { loginErrors != null && loginErrors.length > 0 ? (
+          <p className="alert alert-danger">
+            { loginErrors.map((err, index) => {
+              return <span key={index}>{ t(err.message) }<br/></span>;
+            }) }
+          </p>
+        ) : (
+          <p className="alert alert-success">
+            <strong>{ t('invited.discription_heading') }</strong><br></br>
+            <small>{ t('invited.discription') }</small>
+          </p>
+        ) }
+      </>
+    );
+  }, [isConnectSuccess, loginErrors, t]);
+
   if (user == null) {
     return <></>;
   }
 
   return (
-    <div className="noLogin-dialog p-3 mx-auto" id="noLogin-dialog">
-      <p className="alert alert-success">
-        <strong>{ t('invited.discription_heading') }</strong><br></br>
-        <small>{ t('invited.discription') }</small>
-      </p>
-      <form role="form" action="/invited/activateInvited" method="post" id="invited-form">
+    <div className="noLogin-dialog px-3 pb-3 mx-auto" id="noLogin-dialog">
+      { formNotification() }
+      <form role="form" onSubmit={submitHandler} id="invited-form">
         {/* Email Form */}
         <div className="input-group">
           <div className="input-group-prepend">
@@ -89,11 +149,11 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
             placeholder={t('Password')}
             name="invitedForm[password]"
             required
+            minLength={6}
           />
         </div>
         {/* Create Button */}
-        <div className="input-group justify-content-center d-flex mt-5">
-          <input type="hidden" name="_csrf" value={csrfToken} />
+        <div className="input-group justify-content-center d-flex mt-4">
           <button type="submit" className="btn btn-fill" id="register">
             <div className="eff"></div>
             <span className="btn-label"><i className="icon-user-follow"></i></span>
@@ -101,7 +161,7 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
           </button>
         </div>
       </form>
-      <div className="input-group mt-5 d-flex justify-content-center">
+      <div className="input-group mt-4 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>

+ 4 - 3
packages/app/src/components/Layout/AdminLayout.tsx

@@ -13,6 +13,7 @@ const AdminNotFoundPage = dynamic(() => import('../Admin/NotFoundPage').then(mod
 
 type Props = {
   title: string
+  componentTitle: string
   /**
    * Set the current option of AdminNavigation
    * Expected it is in ["home", "app", "security", "markdown", "customize", "importer", "export",
@@ -24,7 +25,7 @@ type Props = {
 
 
 const AdminLayout = ({
-  children, title, selectedNavOpt,
+  children, title, selectedNavOpt, componentTitle,
 }: Props): JSX.Element => {
 
   const AdminNavigation = dynamic(() => import('~/components/Admin/Common/AdminNavigation'), { ssr: false });
@@ -35,8 +36,8 @@ const AdminLayout = ({
       <div className={`admin-page ${styles['admin-page']}`}>
         <GrowiNavbar />
 
-        <header className="py-0 position-relative">
-          <h1 className="title px-3">{title}</h1>
+        <header className="py-0 container-fluid">
+          <h1 className="title px-3">{componentTitle}</h1>
         </header>
         <div id="main" className="main">
           <div className="container-fluid">

+ 2 - 0
packages/app/src/components/Layout/BasicLayout.tsx

@@ -19,6 +19,7 @@ const PageDeleteModal = dynamic(() => import('../PageDeleteModal'), { ssr: false
 const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false });
 const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
 const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal'), { ssr: false });
+const DrawioModal = dynamic(() => import('../PageEditor/DrawioModal').then(mod => mod.DrawioModal), { ssr: false });
 // Fab
 const Fab = dynamic(() => import('../Fab').then(mod => mod.Fab), { ssr: false });
 
@@ -58,6 +59,7 @@ export const BasicLayout = ({
       <PageRenameModal />
       <PagePresentationModal />
       <PageAccessoriesModal />
+      <DrawioModal />
       <HotkeysManager />
 
       <Fab />

+ 1 - 1
packages/app/src/components/Layout/SearchResultLayout.tsx

@@ -20,7 +20,7 @@ const SearchResultLayout = ({
   }
 
   return (
-    <div className={`${commonStyles['on-search']}`}>
+    <div className={`on-search ${commonStyles['on-search']}`}>
       <BasicLayout title={title} className={classNames.join(' ')}>
         <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
         <div id="main" className="main search-page mt-0">

+ 76 - 23
packages/app/src/components/LoginForm.tsx

@@ -7,6 +7,8 @@ import { useRouter } from 'next/router';
 import ReactCardFlip from 'react-card-flip';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
+import { LoginErrorCode } from '~/interfaces/errors/login-error';
+import { IErrorV3 } from '~/interfaces/errors/v3-error';
 
 type LoginFormProps = {
   username?: string,
@@ -19,6 +21,7 @@ type LoginFormProps = {
   isPasswordResetEnabled: boolean,
   isLocalStrategySetup: boolean,
   isLdapStrategySetup: boolean,
+  isLdapSetupFailed: boolean,
   objOfIsExternalAuthEnableds?: any,
   isMailerSetup?: boolean
 }
@@ -27,7 +30,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
   const router = useRouter();
 
   const {
-    isLocalStrategySetup, isLdapStrategySetup, isPasswordResetEnabled, isRegistrationEnabled,
+    isLocalStrategySetup, isLdapStrategySetup, isLdapSetupFailed, isPasswordResetEnabled, isRegistrationEnabled,
     isEmailAuthenticationEnabled, registrationMode, registrationWhiteList, isMailerSetup, objOfIsExternalAuthEnableds,
   } = props;
   const isLocalOrLdapStrategiesEnabled = isLocalStrategySetup || isLdapStrategySetup;
@@ -38,13 +41,13 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
   // For Login
   const [usernameForLogin, setUsernameForLogin] = useState('');
   const [passwordForLogin, setPasswordForLogin] = useState('');
-  const [loginErrors, setLoginErrors] = useState<Error[]>([]);
+  const [loginErrors, setLoginErrors] = useState<IErrorV3[]>([]);
   // For Register
   const [usernameForRegister, setUsernameForRegister] = useState('');
   const [nameForRegister, setNameForRegister] = useState('');
   const [emailForRegister, setEmailForRegister] = useState('');
   const [passwordForRegister, setPasswordForRegister] = useState('');
-  const [registerErrors, setRegisterErrors] = useState<Error[]>([]);
+  const [registerErrors, setRegisterErrors] = useState<IErrorV3[]>([]);
 
   useEffect(() => {
     const { hash } = window.location;
@@ -60,8 +63,14 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     window.location.href = `/passport/${auth}`;
   }, []);
 
+  const resetLoginErrors = useCallback(() => {
+    if (loginErrors.length === 0) return;
+    setLoginErrors([]);
+  }, [loginErrors.length]);
+
   const handleLoginWithLocalSubmit = useCallback(async(e) => {
     e.preventDefault();
+    resetLoginErrors();
 
     const loginForm = {
       username: usernameForLogin,
@@ -78,25 +87,73 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     }
     return;
 
-  }, [passwordForLogin, router, usernameForLogin]);
+  }, [passwordForLogin, resetLoginErrors, router, usernameForLogin]);
+
+  // separate errors based on error code
+  const separateErrorsBasedOnErrorCode = useCallback((errors: IErrorV3[]) => {
+    const loginErrorListForDangerouslySetInnerHTML: IErrorV3[] = [];
+    const loginErrorList: IErrorV3[] = [];
+
+    errors.forEach((err) => {
+      if (err.code === LoginErrorCode.PROVIDER_DUPLICATED_USERNAME_EXCEPTION) {
+        loginErrorListForDangerouslySetInnerHTML.push(err);
+      }
+      else {
+        loginErrorList.push(err);
+      }
+    });
+
+    return [loginErrorListForDangerouslySetInnerHTML, loginErrorList];
+  }, []);
+
+  // wrap error elements which use dangerouslySetInnerHtml
+  const generateDangerouslySetErrors = useCallback((errors: IErrorV3[]): JSX.Element => {
+    if (errors == null || errors.length === 0) return <></>;
+    return (
+      <div className="alert alert-danger">
+        {errors.map((err, index) => {
+          return <small key={index} dangerouslySetInnerHTML={{ __html: t(err.message, err.args) }}></small>;
+        })}
+      </div>
+    );
+  }, [t]);
+
+  // wrap error elements which do not use dangerouslySetInnerHtml
+  const generateSafelySetErrors = useCallback((errors: IErrorV3[]): JSX.Element => {
+    if (errors == null || errors.length === 0) return <></>;
+    return (
+      <ul className="alert alert-danger">
+        {errors.map((err, index) => {
+          return (
+            <li key={index}>
+              {t(err.message, err.args)}<br/>
+            </li>);
+        })}
+      </ul>
+    );
+  }, [t]);
 
   const renderLocalOrLdapLoginForm = useCallback(() => {
     const { isLdapStrategySetup } = props;
+
+    // separate login errors into two arrays based on error code
+    const [loginErrorListForDangerouslySetInnerHTML, loginErrorList] = separateErrorsBasedOnErrorCode(loginErrors);
+    // Generate login error elements using dangerouslySetInnerHTML
+    const loginErrorElementWithDangerouslySetInnerHTML = generateDangerouslySetErrors(loginErrorListForDangerouslySetInnerHTML);
+    // Generate login error elements using <ul>, <li>
+    const loginErrorElement = generateSafelySetErrors(loginErrorList);
+
     return (
       <>
-        {
-          loginErrors != null && loginErrors.length > 0 && (
-            <p className="alert alert-danger">
-              {loginErrors.map((err, index) => {
-                return (
-                  <span key={index}>
-                    {t(err.message)}<br/>
-                  </span>
-                );
-              })}
-            </p>
-          )
-        }
+        {isLdapSetupFailed && (
+          <div className="alert alert-warning small">
+            <strong><i className="icon-fw icon-info"></i>{t('login.enabled_ldap_has_configuration_problem')}</strong><br/>
+            <span dangerouslySetInnerHTML={{ __html: t('login.set_env_var_for_logs') }}></span>
+          </div>
+        )}
+        {loginErrorElementWithDangerouslySetInnerHTML}
+        {loginErrorElement}
+
         <form role="form" onSubmit={handleLoginWithLocalSubmit} id="login-form">
           <div className="input-group">
             <div className="input-group-prepend">
@@ -137,7 +194,8 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
         </form>
       </>
     );
-  }, [handleLoginWithLocalSubmit, loginErrors, props, t]);
+  }, [generateDangerouslySetErrors, generateSafelySetErrors, handleLoginWithLocalSubmit,
+      isLdapSetupFailed, loginErrors, props, separateErrorsBasedOnErrorCode, t]);
 
   const renderExternalAuthInput = useCallback((auth) => {
     const authIconNames = {
@@ -222,11 +280,6 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     return;
   }, [emailForRegister, nameForRegister, passwordForRegister, router, usernameForRegister]);
 
-  const resetLoginErrors = useCallback(() => {
-    if (loginErrors.length === 0) return;
-    setLoginErrors([]);
-  }, [loginErrors.length]);
-
   const resetRegisterErrors = useCallback(() => {
     if (registerErrors.length === 0) return;
     setRegisterErrors([]);

+ 3 - 4
packages/app/src/components/Me/ProfileImageSettings.tsx

@@ -42,7 +42,7 @@ const ProfileImageSettings = (): JSX.Element => {
     setShowImageCropModal(true);
   }, []);
 
-  const cropCompletedHandler = useCallback(async(croppedImage) => {
+  const processImageCompletedHandler = useCallback(async(croppedImage) => {
     try {
       const formData = new FormData();
       formData.append('file', croppedImage);
@@ -53,8 +53,6 @@ const ProfileImageSettings = (): JSX.Element => {
       // eslint-disable-next-line @typescript-eslint/no-explicit-any
       setUploadedPictureSrc((response as any).attachment.filePathProxied);
 
-      // close modal
-      setShowImageCropModal(false);
     }
     catch (err) {
       toastError(err);
@@ -158,8 +156,9 @@ const ProfileImageSettings = (): JSX.Element => {
         isShow={showImageCropModal}
         src={imageCropSrc}
         onModalClose={() => setShowImageCropModal(false)}
-        onCropCompleted={cropCompletedHandler}
+        onImageProcessCompleted={processImageCompletedHandler}
         isCircular
+        showCropOption
       />
 
       <div className="row my-3">

+ 16 - 14
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -323,7 +323,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
         <div className="d-flex">
           <div className="d-flex flex-column align-items-end justify-content-center py-md-2" style={{ gap: `${isCompactMode ? '5px' : '7px'}` }}>
             { isViewMode && (
-              <div className="h-50 w-100">
+              <div className="h-50">
                 { pageId != null && (
                   <SubNavButtons
                     isCompactMode={isCompactMode}
@@ -385,19 +385,21 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
     : currentPage?.path;
 
   return (
-    <GrowiSubNavigation
-      pagePath={pagePath}
-      pageId={currentPage?._id}
-      showDrawerToggler={isDrawerMode}
-      showTagLabel={isAbleToShowTagLabel}
-      isGuestUser={isGuestUser}
-      isDrawerMode={isDrawerMode}
-      isCompactMode={isCompactMode}
-      tags={isViewMode ? tagsInfoData?.tags : tagsForEditors}
-      tagsUpdatedHandler={isViewMode ? tagsUpdatedHandlerForViewMode : tagsUpdatedHandlerForEditMode}
-      rightComponent={RightComponent}
-      additionalClasses={['container-fluid']}
-    />
+    <div data-testid="grw-contextual-sub-nav">
+      <GrowiSubNavigation
+        pagePath={pagePath}
+        pageId={currentPage?._id}
+        showDrawerToggler={isDrawerMode}
+        showTagLabel={isAbleToShowTagLabel}
+        isGuestUser={isGuestUser}
+        isDrawerMode={isDrawerMode}
+        isCompactMode={isCompactMode}
+        tags={isViewMode ? tagsInfoData?.tags : tagsForEditors}
+        tagsUpdatedHandler={isViewMode ? tagsUpdatedHandlerForViewMode : tagsUpdatedHandlerForEditMode}
+        rightComponent={RightComponent}
+        additionalClasses={['container-fluid']}
+      />
+    </div>
   );
 };
 

+ 43 - 35
packages/app/src/components/Page.tsx

@@ -3,12 +3,14 @@ import React, {
   useEffect, useRef, useState,
 } from 'react';
 
+import EventEmitter from 'events';
+
 import dynamic from 'next/dynamic';
 // import { debounce } from 'throttle-debounce';
 
 import { HtmlElementNode } from 'rehype-toc';
 
-// import { getOptionsToSave } from '~/client/util/editor';
+import { getOptionsToSave } from '~/client/util/editor';
 import {
   useIsGuestUser, useCurrentPageTocNode, useShareLinkId,
 } from '~/stores/context';
@@ -23,11 +25,13 @@ import {
 import loggerFactory from '~/utils/logger';
 
 import RevisionRenderer from './Page/RevisionRenderer';
-
-// TODO: import dynamically
+import { DrawioModal } from './PageEditor/DrawioModal';
 // import MarkdownTable from '~/client/models/MarkdownTable';
-// import mdu from './PageEditor/MarkdownDrawioUtil';
-// import mtu from './PageEditor/MarkdownTableUtil';
+import mdu from './PageEditor/MarkdownDrawioUtil';
+import mtu from './PageEditor/MarkdownTableUtil';
+
+
+declare const globalEmitter: EventEmitter;
 
 // const DrawioModal = dynamic(() => import('./PageEditor/DrawioModal'), { ssr: false });
 const GridEditModal = dynamic(() => import('./PageEditor/GridEditModal'), { ssr: false });
@@ -132,35 +136,35 @@ class PageSubstance extends React.Component<PageSubstanceProps> {
   }
 
   async saveHandlerForDrawioModal(drawioData) {
-    // const {
-    //   isSlackEnabled, slackChannels, pageContainer, pageTags, grant, grantGroupId, grantGroupName, mutateIsEnabledUnsavedWarning,
-    // } = this.props;
-    // const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
-
-    // const newMarkdown = mdu.replaceDrawioInMarkdown(
-    //   drawioData,
-    //   this.props.pageContainer.state.markdown,
-    //   this.state.currentTargetDrawioArea.beginLineNumber,
-    //   this.state.currentTargetDrawioArea.endLineNumber,
-    // );
-
-    // try {
-    //   // disable unsaved warning
-    //   mutateIsEnabledUnsavedWarning(false);
-
-    //   // eslint-disable-next-line no-unused-vars
-    //   const { page, tags } = await pageContainer.save(newMarkdown, this.props.editorMode, optionsToSave);
-    //   logger.debug('success to save');
-
-    //   pageContainer.showSuccessToastr();
-    // }
-    // catch (error) {
-    //   logger.error('failed to save', error);
-    //   pageContainer.showErrorToastr(error);
-    // }
-    // finally {
-    //   this.setState({ currentTargetDrawioArea: null });
-    // }
+  //   const {
+  //     isSlackEnabled, slackChannels, pageContainer, pageTags, grant, grantGroupId, grantGroupName, mutateIsEnabledUnsavedWarning,
+  //   } = this.props;
+  //   const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
+
+    //   const newMarkdown = mdu.replaceDrawioInMarkdown(
+    //     drawioData,
+    //     this.props.pageContainer.state.markdown,
+    //     this.state.currentTargetDrawioArea.beginLineNumber,
+    //     this.state.currentTargetDrawioArea.endLineNumber,
+    //   );
+
+    //   try {
+    //     // disable unsaved warning
+    //     mutateIsEnabledUnsavedWarning(false);
+
+    //     // eslint-disable-next-line no-unused-vars
+    //     const { page, tags } = await pageContainer.save(newMarkdown, this.props.editorMode, optionsToSave);
+    //     logger.debug('success to save');
+
+  //     pageContainer.showSuccessToastr();
+  //   }
+  //   catch (error) {
+  //     logger.error('failed to save', error);
+  //     pageContainer.showErrorToastr(error);
+  //   }
+  //   finally {
+  //     this.setState({ currentTargetDrawioArea: null });
+  //   }
   }
 
   override render() {
@@ -182,7 +186,11 @@ class PageSubstance extends React.Component<PageSubstanceProps> {
             <GridEditModal ref={this.gridEditModal} />
             <LinkEditModal ref={this.linkEditModal} />
             {/* <HandsontableModal ref={this.handsontableModal} onSave={this.saveHandlerForHandsontableModal} /> */}
-            {/* <DrawioModal ref={this.drawioModal} onSave={this.saveHandlerForDrawioModal} /> */}
+            {/* TODO: use global DrawioModal https://redmine.weseek.co.jp/issues/105981 */}
+            {/* <DrawioModal
+              ref={this.drawioModal}
+              onSave={this.saveHandlerForDrawioModal}
+            /> */}
           </>
         )}
       </div>

+ 6 - 4
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -13,10 +13,12 @@ import { useSWRxCurrentPage } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 
 import CountBadge from '../Common/CountBadge';
+import { ContentLinkButtonsProps } from '../ContentLinkButtons';
 import CustomTabContent from '../CustomNavigation/CustomTabContent';
 import PageListIcon from '../Icons/PageListIcon';
 import { Page } from '../Page';
 import TableOfContents from '../TableOfContents';
+import { UserInfoProps } from '../User/UserInfo';
 
 import styles from './DisplaySwitcher.module.scss';
 
@@ -27,9 +29,9 @@ const PageEditor = dynamic(() => import('../PageEditor'), { ssr: false });
 const PageEditorByHackmd = dynamic(() => import('../PageEditorByHackmd').then(mod => mod.PageEditorByHackmd), { ssr: false });
 const EditorNavbarBottom = dynamic(() => import('../PageEditor/EditorNavbarBottom'), { ssr: false });
 const HashChanged = dynamic(() => import('../EventListeneres/HashChanged'), { ssr: false });
-const ContentLinkButtons = dynamic(() => import('../ContentLinkButtons').then(mod => mod.ContentLinkButtons), { ssr: false });
+const ContentLinkButtons = dynamic<ContentLinkButtonsProps>(() => import('../ContentLinkButtons').then(mod => mod.ContentLinkButtons), { ssr: false });
 const NotFoundPage = dynamic(() => import('../NotFoundPage'), { ssr: false });
-const UserInfo = dynamic(() => import('../User/UserInfo').then(mod => mod.UserInfo), { ssr: false });
+const UserInfo = dynamic<UserInfoProps>(() => import('../User/UserInfo').then(mod => mod.UserInfo), { ssr: false });
 
 
 const PageView = React.memo((): JSX.Element => {
@@ -49,7 +51,7 @@ const PageView = React.memo((): JSX.Element => {
     <div className="d-flex flex-column flex-lg-row">
 
       <div className="flex-grow-1 flex-basis-0 mw-0">
-        { isUsersHomePagePath && <UserInfo /> }
+        { isUsersHomePagePath && <UserInfo author={currentPage?.creator} /> }
         { !isNotFound && <Page /> }
         { isNotFound && <NotFoundPage /> }
       </div>
@@ -94,7 +96,7 @@ const PageView = React.memo((): JSX.Element => {
 
             <div className="d-none d-lg-block">
               <TableOfContents />
-              { isUsersHomePagePath && <ContentLinkButtons /> }
+              { isUsersHomePagePath && <ContentLinkButtons author={currentPage?.creator} /> }
             </div>
 
           </div>

+ 0 - 138
packages/app/src/components/Page/RevisionLoader.jsx

@@ -1,138 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import { Waypoint } from 'react-waypoint';
-
-import { apiv3Get } from '~/client/util/apiv3-client';
-import { RendererOptions } from '~/services/renderer/renderer';
-import loggerFactory from '~/utils/logger';
-
-import RevisionRenderer from './RevisionRenderer';
-
-
-/**
- * Load data from server and render RevisionBody component
- */
-class RevisionLoader extends React.Component {
-
-  constructor(props) {
-    super(props);
-    this.logger = loggerFactory('growi:Page:RevisionLoader');
-
-    this.state = {
-      markdown: null,
-      isLoading: false,
-      isLoaded: false,
-      errors: null,
-    };
-
-    this.loadData = this.loadData.bind(this);
-    this.onWaypointChange = this.onWaypointChange.bind(this);
-  }
-
-  UNSAFE_componentWillMount() {
-    if (!this.props.lazy) {
-      this.loadData();
-    }
-  }
-
-  async loadData() {
-    if (!this.state.isLoaded && !this.state.isLoading) {
-      this.setState({ isLoading: true });
-    }
-
-    const { pageId, revisionId } = this.props;
-
-
-    // load data with REST API
-    try {
-      const res = await apiv3Get(`/revisions/${revisionId}`, { pageId });
-
-      this.setState({
-        markdown: res.data?.revision?.body,
-        errors: null,
-      });
-
-      if (this.props.onRevisionLoaded != null) {
-        this.props.onRevisionLoaded(res.data.revision);
-      }
-    }
-    catch (errors) {
-      this.setState({ errors });
-    }
-    finally {
-      this.setState({ isLoaded: true, isLoading: false });
-    }
-
-  }
-
-  onWaypointChange(event) {
-    if (event.currentPosition === Waypoint.above || event.currentPosition === Waypoint.inside) {
-      this.loadData();
-    }
-  }
-
-  render() {
-    // ----- before load -----
-    if (this.props.lazy && !this.state.isLoaded) {
-      return (
-        <Waypoint onPositionChange={this.onWaypointChange} bottomOffset="-100px">
-          <div className="wiki"></div>
-        </Waypoint>
-      );
-    }
-
-    // ----- loading -----
-    if (this.state.isLoading) {
-      return (
-        <div className="wiki">
-          <div className="text-muted text-center">
-            <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
-          </div>
-        </div>
-      );
-    }
-
-    // ----- after load -----
-    const isForbidden = this.state.errors != null && this.state.errors[0].code === 'forbidden-page';
-    let markdown = this.state.markdown;
-    if (isForbidden) {
-      markdown = `<i class="icon-exclamation p-1"></i>${this.props.t('not_allowed_to_see_this_page')}`;
-    }
-    else if (this.state.errors != null) {
-      const errorMessages = this.state.errors.map((error) => {
-        return `<i class="icon-exclamation p-1"></i><span class="text-muted"><em>${error.message}</em></span>`;
-      });
-      markdown = errorMessages.join('\n');
-    }
-
-    return (
-      <RevisionRenderer
-        rendererOptions={this.props.rendererOptions}
-        markdown={markdown}
-      />
-    );
-  }
-
-}
-
-
-RevisionLoader.propTypes = {
-  t: PropTypes.func.isRequired,
-
-  rendererOptions: PropTypes.instanceOf(RendererOptions).isRequired,
-  pageId: PropTypes.string.isRequired,
-  pagePath: PropTypes.string.isRequired,
-  revisionId: PropTypes.string.isRequired,
-  lazy: PropTypes.bool,
-  onRevisionLoaded: PropTypes.func,
-  highlightKeywords: PropTypes.arrayOf(PropTypes.string),
-};
-
-const RevisionLoaderWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <RevisionLoader t={t} {...props} />;
-};
-
-export default RevisionLoaderWrapperFC;

+ 118 - 0
packages/app/src/components/Page/RevisionLoader.tsx

@@ -0,0 +1,118 @@
+import React, { useEffect, useState, useCallback } from 'react';
+
+import { Ref, IRevision, IRevisionHasId } from '@growi/core';
+import { useTranslation } from 'next-i18next';
+import { Waypoint } from 'react-waypoint';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { RendererOptions } from '~/services/renderer/renderer';
+import loggerFactory from '~/utils/logger';
+
+import RevisionRenderer from './RevisionRenderer';
+
+export type RevisionLoaderProps = {
+  rendererOptions: RendererOptions,
+  pageId: string,
+  revisionId: Ref<IRevision>,
+  lazy?: boolean,
+  onRevisionLoaded?: (revision: IRevisionHasId) => void,
+
+  pagePath: string,
+  highlightKeywords?: string[],
+}
+
+const logger = loggerFactory('growi:Page:RevisionLoader');
+
+/**
+ * Load data from server and render RevisionBody component
+ */
+export const RevisionLoader = (props: RevisionLoaderProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    rendererOptions, pageId, revisionId, lazy, onRevisionLoaded,
+  } = props;
+
+  const [isLoading, setIsLoading] = useState<boolean>();
+  const [isLoaded, setIsLoaded] = useState<boolean>();
+  const [markdown, setMarkdown] = useState<string>('');
+  const [errors, setErrors] = useState<any | null>();
+
+  const loadData = useCallback(async() => {
+    if (!isLoaded && !isLoading) {
+      setIsLoading(true);
+    }
+
+    // load data with REST API
+    try {
+      const res = await apiv3Get(`/revisions/${revisionId}`, { pageId });
+
+      setMarkdown(res.data?.revision?.body);
+      setErrors(null);
+
+      if (onRevisionLoaded != null) {
+        onRevisionLoaded(res.data.revision);
+      }
+    }
+    catch (errors) {
+      setErrors(errors);
+    }
+    finally {
+      setIsLoaded(true);
+      setIsLoading(false);
+    }
+
+  }, [isLoaded, isLoading, onRevisionLoaded, pageId, revisionId]);
+
+  useEffect(() => {
+    if (!lazy) {
+      loadData();
+    }
+  }, [lazy, loadData]);
+
+  const onWaypointChange = (event) => {
+    if (event.currentPosition === Waypoint.above || event.currentPosition === Waypoint.inside) {
+      loadData();
+    }
+    return;
+  };
+
+  /* ----- before load ----- */
+  if (lazy && !isLoaded) {
+    return (
+      <Waypoint onPositionChange={onWaypointChange} bottomOffset="-100px">
+        <div className="wiki"></div>
+      </Waypoint>
+    );
+  }
+
+  /* ----- loading ----- */
+  if (isLoading) {
+    return (
+      <div className="wiki">
+        <div className="text-muted text-center">
+          <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
+        </div>
+      </div>
+    );
+  }
+
+  /* ----- after load ----- */
+  const isForbidden = errors != null && errors[0].code === 'forbidden-page';
+  if (isForbidden) {
+    setMarkdown(`<i class="icon-exclamation p-1"></i>${t('not_allowed_to_see_this_page')}`);
+  }
+  else if (errors != null) {
+    const errorMessages = errors.map((error) => {
+      return `<i class="icon-exclamation p-1"></i><span class="text-muted"><em>${error.message}</em></span>`;
+    });
+    setMarkdown(errorMessages.join('\n'));
+  }
+
+  return (
+    <RevisionRenderer
+      rendererOptions={rendererOptions}
+      markdown={markdown}
+    />
+  );
+};

+ 1 - 1
packages/app/src/components/Page/TagLabels.tsx

@@ -36,7 +36,7 @@ export const TagLabels:FC<Props> = (props: Props) => {
 
   return (
     <>
-      <div className={`${styles['grw-tag-labels']} grw-tag-labels d-flex align-items-center`}>
+      <div className={`${styles['grw-tag-labels']} grw-tag-labels d-flex align-items-center`} data-testid="grw-tag-labels">
         <i className="tag-icon icon-tag mr-2"></i>
         <RenderTagLabels
           tags={tags}

+ 0 - 6
packages/app/src/components/PageComment/CommentEditor.module.scss

@@ -30,10 +30,4 @@
       padding-top: 0.5em;
     }
   }
-
-  .page-comment-editor-skelton {
-    height: comment-inheritance.$codemirror-default-height;
-    margin-top: page-editor-inheritance.$navbar-editor-height;
-    margin-bottom: bs.$line-height-base + bs.$btn-padding-y;
-  }
 }

+ 20 - 15
packages/app/src/components/PageComment/CommentEditor.tsx

@@ -20,7 +20,7 @@ import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
 import NotAvailableForGuest from '../NotAvailableForGuest';
-import { Skelton } from '../Skelton';
+import Editor from '../PageEditor/Editor';
 
 
 import { CommentPreview } from './CommentPreview';
@@ -29,11 +29,6 @@ import styles from './CommentEditor.module.scss';
 
 
 const SlackNotification = dynamic(() => import('../SlackNotification').then(mod => mod.SlackNotification), { ssr: false });
-const Editor = dynamic(() => import('../PageEditor/Editor'),
-  {
-    ssr: false,
-    loading: () => <Skelton additionalClass="grw-skelton page-comment-editor-skelton" />,
-  });
 
 
 const navTabMapping = {
@@ -81,7 +76,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
   const [comment, setComment] = useState(commentBody ?? '');
   const [activeTab, setActiveTab] = useState('comment_editor');
   const [error, setError] = useState();
-  const [slackChannels, setSlackChannels] = useState(slackChannelsData?.toString());
+  const [slackChannels, setSlackChannels] = useState<string>('');
 
   const editorRef = useRef<IEditorMethods>(null);
 
@@ -90,9 +85,19 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
   }, []);
 
   useEffect(() => {
-    if (slackChannels === undefined) { return }
-    setSlackChannels(slackChannelsData?.toString());
-  }, [slackChannelsData, slackChannels]);
+    if (slackChannelsData != null) {
+      setSlackChannels(slackChannelsData.toString());
+      mutateIsSlackEnabled(false);
+    }
+  }, [mutateIsSlackEnabled, slackChannelsData]);
+
+  const isSlackEnabledToggleHandler = (isSlackEnabled: boolean) => {
+    mutateIsSlackEnabled(isSlackEnabled, false);
+  };
+
+  const slackChannelsChangedHandler = useCallback((slackChannels: string) => {
+    setSlackChannels(slackChannels);
+  }, []);
 
   const initializeEditor = useCallback(() => {
     setComment('');
@@ -289,14 +294,14 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
             <span className="flex-grow-1" />
             <span className="d-none d-sm-inline">{ errorMessage && errorMessage }</span>
 
-            { isSlackConfigured
+            { isSlackConfigured && isSlackEnabled != null
               && (
                 <div className="form-inline align-self-center mr-md-2">
                   <SlackNotification
-                    isSlackEnabled
-                    slackChannels={slackChannelsData?.toString() ?? ''}
-                    onEnabledFlagChange={isSlackEnabled => mutateIsSlackEnabled(isSlackEnabled, false)}
-                    onChannelChange={setSlackChannels}
+                    isSlackEnabled={isSlackEnabled}
+                    slackChannels={slackChannels}
+                    onEnabledFlagChange={isSlackEnabledToggleHandler}
+                    onChannelChange={slackChannelsChangedHandler}
                     id="idForComment"
                   />
                 </div>

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

@@ -5,6 +5,7 @@ import React, {
 import { pagePathUtils, pathUtils } from '@growi/core';
 import { format } from 'date-fns';
 import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 
@@ -21,6 +22,7 @@ const {
 
 const PageCreateModal = () => {
   const { t } = useTranslation();
+  const router = useRouter();
 
   const { data: currentUser } = useCurrentUser();
 
@@ -98,7 +100,10 @@ const PageCreateModal = () => {
   async function redirectToEditor(...paths) {
     try {
       const editorPath = await generateEditorPath(...paths);
-      window.location.href = editorPath;
+      router.push(editorPath);
+
+      // close modal
+      closeCreateModal();
     }
     catch (err) {
       toastError(err);
@@ -203,7 +208,7 @@ const PageCreateModal = () => {
               {isReachable
                 ? (
                   <PagePathAutoComplete
-                    initializedPath={pageNameInput}
+                    initializedPath={pageNameInputInitialValue}
                     addTrailingSlash
                     onSubmit={ppacSubmitHandler}
                     onInputChange={value => setPageNameInput(value)}

+ 6 - 4
packages/app/src/components/PageEditor.tsx

@@ -13,7 +13,8 @@ import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import {
-  useIsEditable, useIsIndentSizeForced, useCurrentPagePath, useCurrentPathname, useCurrentPageId, useIsUploadableFile, useIsUploadableImage,
+  useCurrentPagePath, useCurrentPathname, useCurrentPageId, useEditingMarkdown,
+  useIsEditable, useIsIndentSizeForced, useIsUploadableFile, useIsUploadableImage,
 } from '~/stores/context';
 import {
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
@@ -51,6 +52,7 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
+  const { data: editingMarkdown } = useEditingMarkdown();
   const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: pageTags } = usePageTagsForEditors(pageId);
 
@@ -69,10 +71,10 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: rendererOptions } = usePreviewOptions();
 
   const currentRevisionId = currentPage?.revision?._id;
-  const initialValue = currentPage?.revision?.body;
+  const initialValue = editingMarkdown ?? '';
 
-  const markdownToSave = useRef<string>(initialValue ?? '');
-  const [markdownToPreview, setMarkdownToPreview] = useState<string>(initialValue ?? '');
+  const markdownToSave = useRef<string>(initialValue);
+  const [markdownToPreview, setMarkdownToPreview] = useState<string>(initialValue);
 
   const slackChannels = useMemo(() => (slackChannelsData ? slackChannelsData.toString() : ''), [slackChannelsData]);
 

+ 120 - 45
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useCallback } from 'react';
 
 import { createValidator } from '@growi/codemirror-textlint';
 import { commands } from 'codemirror';
@@ -7,16 +7,18 @@ import * as loadCssSync from 'load-css-file';
 import PropTypes from 'prop-types';
 import { Button } from 'reactstrap';
 import * as loadScript from 'simple-load-script';
+import { throttle, debounce } from 'throttle-debounce';
 import urljoin from 'url-join';
 
 import InterceptorManager from '~/services/interceptor-manager';
+import { useDrawioModal } from '~/stores/modal';
 import loggerFactory from '~/utils/logger';
 
 import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 
 import AbstractEditor from './AbstractEditor';
 import CommentMentionHelper from './CommentMentionHelper';
-// import DrawioModal from './DrawioModal';
+import { DrawioModal } from './DrawioModal';
 import EditorIcon from './EditorIcon';
 import EmojiPicker from './EmojiPicker';
 import EmojiPickerHelper from './EmojiPickerHelper';
@@ -105,7 +107,9 @@ class CodeMirrorEditor extends AbstractEditor {
       isCheatsheetModalShown: false,
       additionalClassSet: new Set(),
       isEmojiPickerShown: false,
-      emojiSearchText: null,
+      emojiSearchText: '',
+      startPosWithEmojiPickerModeTurnedOn: null,
+      isEmojiPickerMode: false,
     };
 
     this.cm = React.createRef();
@@ -131,7 +135,16 @@ class CodeMirrorEditor extends AbstractEditor {
     this.pasteHandler = this.pasteHandler.bind(this);
     this.cursorHandler = this.cursorHandler.bind(this);
     this.changeHandler = this.changeHandler.bind(this);
-    this.keyUpHandler = this.keyUpHandler.bind(this);
+    this.turnOnEmojiPickerMode = this.turnOnEmojiPickerMode.bind(this);
+    this.turnOffEmojiPickerMode = this.turnOffEmojiPickerMode.bind(this);
+    this.windowClickHandler = this.windowClickHandler.bind(this);
+    this.keyDownHandler = this.keyDownHandler.bind(this);
+    this.keyDownHandlerForEmojiPicker = this.keyDownHandlerForEmojiPicker.bind(this);
+    this.keyDownHandlerForEmojiPickerThrottled = throttle(400, this.keyDownHandlerForEmojiPicker);
+    this.showEmojiPicker = this.showEmojiPicker.bind(this);
+    this.keyPressHandlerForEmojiPicker = this.keyPressHandlerForEmojiPicker.bind(this);
+    this.keyPressHandlerForEmojiPickerThrottled = debounce(50, throttle(200, this.keyPressHandlerForEmojiPicker));
+    this.keyPressHandler = this.keyPressHandler.bind(this);
 
     this.updateCheatsheetStates = this.updateCheatsheetStates.bind(this);
 
@@ -142,11 +155,9 @@ class CodeMirrorEditor extends AbstractEditor {
     this.showGridEditorHandler = this.showGridEditorHandler.bind(this);
     this.showLinkEditHandler = this.showLinkEditHandler.bind(this);
     this.showHandsonTableHandler = this.showHandsonTableHandler.bind(this);
-    this.showDrawioHandler = this.showDrawioHandler.bind(this);
 
     this.foldDrawioSection = this.foldDrawioSection.bind(this);
     this.onSaveForDrawio = this.onSaveForDrawio.bind(this);
-    this.checkWhetherEmojiPickerShouldBeShown = this.checkWhetherEmojiPickerShouldBeShown.bind(this);
 
   }
 
@@ -180,6 +191,13 @@ class CodeMirrorEditor extends AbstractEditor {
     }
     this.emojiPickerHelper = new EmojiPickerHelper(this.getCodeMirror());
 
+    // HACKME: Find a better way to handle onClick for Editor
+    document.addEventListener('click', this.windowClickHandler);
+  }
+
+  componentWillUnmount() {
+    // HACKME: Find a better way to handle onClick for Editor
+    document.removeEventListener('click', this.windowClickHandler);
   }
 
   componentWillReceiveProps(nextProps) {
@@ -583,12 +601,84 @@ class CodeMirrorEditor extends AbstractEditor {
 
   }
 
-  keyUpHandler(editor, event) {
-    if (event.key !== 'Backspace') {
-      this.checkWhetherEmojiPickerShouldBeShown();
+  turnOnEmojiPickerMode(pos) {
+    this.setState({
+      isEmojiPickerMode: true,
+      startPosWithEmojiPickerModeTurnedOn: pos,
+    });
+  }
+
+  turnOffEmojiPickerMode() {
+    this.setState({
+      isEmojiPickerMode: false,
+    });
+  }
+
+  showEmojiPicker(initialSearchingText) {
+    // show emoji picker with a stored word
+    this.setState({
+      isEmojiPickerShown: true,
+      emojiSearchText: initialSearchingText ?? '',
+    });
+
+    const resetStartPos = initialSearchingText == null;
+    if (resetStartPos) {
+      this.setState({ startPosWithEmojiPickerModeTurnedOn: null });
+    }
+
+    this.turnOffEmojiPickerMode();
+  }
+
+  keyPressHandlerForEmojiPicker(editor, event) {
+    const char = event.key;
+    const isEmojiPickerMode = this.state.isEmojiPickerMode;
+
+    // evaluate whether emoji picker mode to be turned on
+    if (!isEmojiPickerMode) {
+      const startPos = this.emojiPickerHelper.shouldModeTurnOn(char);
+      if (startPos == null) {
+        return;
+      }
+
+      this.turnOnEmojiPickerMode(startPos);
+      return;
+    }
+
+    // evaluate whether EmojiPicker to be opened
+    const startPos = this.state.startPosWithEmojiPickerModeTurnedOn;
+    if (this.emojiPickerHelper.shouldOpen(startPos)) {
+      const initialSearchingText = this.emojiPickerHelper.getInitialSearchingText(startPos);
+      this.showEmojiPicker(initialSearchingText);
+      return;
+    }
+
+    this.turnOffEmojiPickerMode();
+  }
+
+  keyPressHandler(editor, event) {
+    this.keyPressHandlerForEmojiPickerThrottled(editor, event);
+  }
+
+  keyDownHandlerForEmojiPicker(editor, event) {
+    const key = event.key;
+
+    if (!this.state.isEmojiPickerMode) {
+      return;
+    }
+
+    if (['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'BackSpace'].includes(key)) {
+      this.turnOffEmojiPickerMode();
     }
   }
 
+  keyDownHandler(editor, event) {
+    this.keyDownHandlerForEmojiPickerThrottled(editor, event);
+  }
+
+  windowClickHandler() {
+    this.turnOffEmojiPickerMode();
+  }
+
   /**
    * CodeMirror paste event handler
    * see: https://codemirror.net/doc/manual.html#events
@@ -610,26 +700,6 @@ class CodeMirrorEditor extends AbstractEditor {
 
   }
 
-  /**
-   * Show emoji picker component when emoji pattern (`:` + searchWord ) found
-   * eg `:a`, `:ap`
-   */
-  checkWhetherEmojiPickerShouldBeShown() {
-    const searchWord = this.emojiPickerHelper.getEmoji();
-
-    if (searchWord == null) {
-      this.setState({ isEmojiPickerShown: false });
-      this.setState({ emojiSearchText: null });
-    }
-    else {
-      this.setState({ emojiSearchText: searchWord });
-      // Show emoji picker after user stop typing
-      setTimeout(() => {
-        this.setState({ isEmojiPickerShown: true });
-      }, 700);
-    }
-  }
-
   /**
    * update states which related to cheatsheet
    * @param {boolean} isGfmModeTmp (use state.isGfmMode if null is set)
@@ -709,7 +779,8 @@ class CodeMirrorEditor extends AbstractEditor {
         <div className="text-left">
           <div className="mb-2 d-none d-md-block">
             <EmojiPicker
-              onClose={() => this.setState({ isEmojiPickerShown: false, emojiSearchText: null })}
+              onClose={() => this.setState({ isEmojiPickerShown: false })}
+              onSelected={emoji => this.emojiPickerHelper.addEmoji(emoji, this.state.startPosWithEmojiPickerModeTurnedOn)}
               emojiSearchText={emojiSearchText}
               emojiPickerHelper={this.emojiPickerHelper}
               isOpen={this.state.isEmojiPickerShown}
@@ -799,10 +870,6 @@ class CodeMirrorEditor extends AbstractEditor {
     // this.handsontableModal.current.show(mtu.getMarkdownTable(this.getCodeMirror()));
   }
 
-  showDrawioHandler() {
-    // this.drawioModal.current.show(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()));
-  }
-
 
   // fold draw.io section (::: drawio ~ :::)
   foldDrawioSection() {
@@ -954,7 +1021,7 @@ class CodeMirrorEditor extends AbstractEditor {
         color={null}
         bssize="small"
         title="draw.io"
-        onClick={this.showDrawioHandler}
+        onClick={() => this.props.onClickDrawioBtn(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()))}
       >
         <EditorIcon icon="Drawio" />
       </Button>,
@@ -963,7 +1030,7 @@ class CodeMirrorEditor extends AbstractEditor {
         color={null}
         bssize="small"
         title="Emoji"
-        onClick={() => this.setState({ isEmojiPickerShown: true })}
+        onClick={() => this.showEmojiPicker()}
       >
         <EditorIcon icon="Emoji" />
       </Button>,
@@ -1043,7 +1110,8 @@ class CodeMirrorEditor extends AbstractEditor {
               this.props.onDragEnter(event);
             }
           }}
-          onKeyUp={this.keyUpHandler}
+          onKeyPress={this.keyPressHandler}
+          onKeyDown={this.keyDownHandler}
         />
 
         { this.renderLoadingKeymapOverlay() }
@@ -1059,17 +1127,11 @@ class CodeMirrorEditor extends AbstractEditor {
           ref={this.linkEditModal}
           onSave={(linkText) => { return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
         />
-        {/*
-        <HandsontableModal
+        {/* <HandsontableModal
           ref={this.handsontableModal}
           onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}
           autoFormatMarkdownTable={this.props.editorSettings.autoFormatMarkdownTable}
         /> */}
-        {/* <DrawioModal
-          ref={this.drawioModal}
-          onSave={this.onSaveForDrawio}
-        /> */}
-
       </div>
     );
   }
@@ -1088,4 +1150,17 @@ CodeMirrorEditor.defaultProps = {
   lineNumbers: true,
 };
 
-export default CodeMirrorEditor;
+
+const CodeMirrorEditorFc = React.forwardRef((props, ref) => {
+  const { open: openDrawioModal } = useDrawioModal();
+
+  const openDrawioModalHandler = useCallback((drawioMxFile) => {
+    openDrawioModal(drawioMxFile);
+  }, [openDrawioModal]);
+
+  return <CodeMirrorEditor ref={ref} onClickDrawioBtn={openDrawioModalHandler} {...props} />;
+});
+
+CodeMirrorEditorFc.displayName = 'CodeMirrorEditorFc';
+
+export default CodeMirrorEditorFc;

+ 94 - 0
packages/app/src/components/PageEditor/DrawioCommunicationHelper.ts

@@ -0,0 +1,94 @@
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:cli:DrawioCommunicationHelper');
+
+export type DrawioConfig = {
+  css: string,
+  customFonts: string[],
+}
+
+export type DrawioCommunicationCallbackOptions = {
+  onClose?: () => void;
+  onSave?: (drawioData: string) => void;
+}
+
+export class DrawioCommunicationHelper {
+
+  drawioUri: string;
+
+  drawioConfig: DrawioConfig;
+
+  callbackOpts?: DrawioCommunicationCallbackOptions;
+
+
+  constructor(drawioUri: string, drawioConfig: DrawioConfig, callbackOpts?: DrawioCommunicationCallbackOptions) {
+    this.drawioUri = drawioUri;
+    this.drawioConfig = drawioConfig;
+    this.callbackOpts = callbackOpts;
+  }
+
+  onReceiveMessage(event: MessageEvent, drawioMxFile: string): void {
+
+    // check origin
+    if (event.origin != null && this.drawioUri != null) {
+      const originUrl = new URL(event.origin);
+      const drawioUrl = new URL(this.drawioUri);
+
+      if (originUrl.origin !== drawioUrl.origin) {
+        logger.debug(`Skipping the event because the origins are mismatched. expected: '${drawioUrl.origin}', actual: '${originUrl.origin}'`);
+        return;
+      }
+    }
+
+    if (event.data === 'ready') {
+      event.source?.postMessage(drawioMxFile, { targetOrigin: '*' });
+      return;
+    }
+
+    if (event.data === '{"event":"configure"}') {
+      if (event.source == null) {
+        return;
+      }
+
+      // refs:
+      //  * https://desk.draw.io/support/solutions/articles/16000103852-how-to-customise-the-draw-io-interface
+      //  * https://desk.draw.io/support/solutions/articles/16000042544-how-does-embed-mode-work-
+      //  * https://desk.draw.io/support/solutions/articles/16000058316-how-to-configure-draw-io-
+      event.source.postMessage(JSON.stringify({
+        action: 'configure',
+        config: this.drawioConfig,
+      }), { targetOrigin: '*' });
+
+      return;
+    }
+
+    if (typeof event.data === 'string' && event.data.match(/mxfile/)) {
+      if (event.data.length > 0) {
+        const parser = new DOMParser();
+        const dom = parser.parseFromString(event.data, 'text/xml');
+        const drawioData = dom.getElementsByTagName('diagram')[0].innerHTML;
+
+        /*
+        * Saving Drawio will be implemented by the following tasks
+        * https://redmine.weseek.co.jp/issues/100845
+        * https://redmine.weseek.co.jp/issues/104507
+        */
+
+        this.callbackOpts?.onSave?.(drawioData);
+      }
+
+      this.callbackOpts?.onClose?.();
+
+      return;
+    }
+
+    if (typeof event.data === 'string' && event.data.length === 0) {
+      this.callbackOpts?.onClose?.();
+      return;
+    }
+
+    // NOTHING DONE. (Receive unknown iframe message.)
+  }
+
+}

+ 0 - 172
packages/app/src/components/PageEditor/DrawioModal.jsx

@@ -1,172 +0,0 @@
-import React from 'react';
-
-import i18next from 'i18next';
-import PropTypes from 'prop-types';
-import {
-  Modal,
-  ModalBody,
-} from 'reactstrap';
-
-import { getDiagramsNetLangCode } from '~/client/util/locale-utils';
-
-class DrawioModal extends React.PureComponent {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      show: false,
-      drawioMxFile: '',
-    };
-
-    this.headerColor = '#334455';
-    this.fontFamily = "Lato, -apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif";
-
-    this.init = this.init.bind(this);
-    this.cancel = this.cancel.bind(this);
-    this.receiveFromDrawio = this.receiveFromDrawio.bind(this);
-  }
-
-  init(drawioMxFile) {
-    const initDrawioMxFile = drawioMxFile;
-    this.setState(
-      {
-        drawioMxFile: initDrawioMxFile,
-      },
-    );
-  }
-
-  show(drawioMxFile) {
-    this.init(drawioMxFile);
-
-    window.addEventListener('message', this.receiveFromDrawio);
-    this.setState({ show: true });
-  }
-
-  hide() {
-    this.setState({
-      show: false,
-    });
-  }
-
-  cancel() {
-    this.hide();
-  }
-
-  receiveFromDrawio(event) {
-    if (event.data === 'ready') {
-      event.source.postMessage(this.state.drawioMxFile, '*');
-      return;
-    }
-
-    if (event.data === '{"event":"configure"}') {
-      if (event.source == null) {
-        return;
-      }
-
-      // refs:
-      //  * https://desk.draw.io/support/solutions/articles/16000103852-how-to-customise-the-draw-io-interface
-      //  * https://desk.draw.io/support/solutions/articles/16000042544-how-does-embed-mode-work-
-      //  * https://desk.draw.io/support/solutions/articles/16000058316-how-to-configure-draw-io-
-      event.source.postMessage(JSON.stringify({
-        action: 'configure',
-        config: {
-          css: `
-          .geMenubarContainer { background-color: ${this.headerColor} !important; }
-          .geMenubar { background-color: ${this.headerColor} !important; }
-          .geEditor { font-family: ${this.fontFamily} !important; }
-          html td.mxPopupMenuItem {
-            font-family: ${this.fontFamily} !important;
-            font-size: 8pt !important;
-          }
-          `,
-          customFonts: ['Lato', 'Charter'],
-        },
-      }), '*');
-
-      return;
-    }
-
-    if (typeof event.data === 'string' && event.data.match(/mxfile/)) {
-      if (event.data.length > 0) {
-        const parser = new DOMParser();
-        const dom = parser.parseFromString(event.data, 'text/xml');
-        const value = dom.getElementsByTagName('diagram')[0].innerHTML;
-
-        if (this.props.onSave != null) {
-          this.props.onSave(value);
-        }
-      }
-
-      window.removeEventListener('message', this.receiveFromDrawio);
-      this.hide();
-
-      return;
-    }
-
-    if (typeof event.data === 'string' && event.data.length === 0) {
-      window.removeEventListener('message', this.receiveFromDrawio);
-      this.hide();
-
-      return;
-    }
-
-    // NOTHING DONE. (Receive unknown iframe message.)
-  }
-
-  get drawioURL() {
-    const { config } = this.props.appContainer;
-
-    const drawioUri = config.env.DRAWIO_URI || 'https://embed.diagrams.net/';
-    const url = new URL(drawioUri);
-
-    // refs: https://desk.draw.io/support/solutions/articles/16000042546-what-url-parameters-are-supported-
-    url.searchParams.append('spin', 1);
-    url.searchParams.append('embed', 1);
-    url.searchParams.append('lang', getDiagramsNetLangCode(i18next.language));
-    url.searchParams.append('ui', 'atlas');
-    url.searchParams.append('configure', 1);
-
-    return url;
-  }
-
-  render() {
-    return (
-      <Modal
-        isOpen={this.state.show}
-        toggle={this.cancel}
-        backdrop="static"
-        className="drawio-modal grw-body-only-modal-expanded"
-        size="xl"
-        keyboard={false}
-      >
-        <ModalBody className="p-0">
-          {/* Loading spinner */}
-          <div className="w-100 h-100 position-absolute d-flex">
-            <div className="mx-auto my-auto">
-              <i className="fa fa-3x fa-spinner fa-pulse mx-auto text-muted"></i>
-            </div>
-          </div>
-          {/* iframe */}
-          <div className="w-100 h-100 position-absolute d-flex">
-            { this.state.show && (
-              <iframe
-                src={this.drawioURL}
-                className="border-0 flex-grow-1"
-              >
-              </iframe>
-            ) }
-          </div>
-        </ModalBody>
-      </Modal>
-    );
-  }
-
-}
-
-DrawioModal.propTypes = {
-  onSave: PropTypes.func,
-};
-
-
-export default DrawioModal;

+ 131 - 0
packages/app/src/components/PageEditor/DrawioModal.tsx

@@ -0,0 +1,131 @@
+import React, {
+  useCallback,
+  useEffect,
+  useMemo,
+} from 'react';
+
+import {
+  Modal,
+  ModalBody,
+} from 'reactstrap';
+
+
+import { getDiagramsNetLangCode } from '~/client/util/locale-utils';
+import { useDrawioUri } from '~/stores/context';
+import { useDrawioModal } from '~/stores/modal';
+import { usePersonalSettings } from '~/stores/personal-settings';
+
+import { DrawioCommunicationHelper } from './DrawioCommunicationHelper';
+
+
+const headerColor = '#334455';
+const fontFamily = "Lato, -apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif";
+
+const drawioConfig = {
+  css: `
+  .geMenubarContainer { background-color: ${headerColor} !important; }
+  .geMenubar { background-color: ${headerColor} !important; }
+  .geEditor { font-family: ${fontFamily} !important; }
+  html td.mxPopupMenuItem {
+    font-family: ${fontFamily} !important;
+    font-size: 8pt !important;
+  }
+  `,
+  customFonts: ['Lato', 'Charter'],
+};
+
+
+type Props = {
+  // onSave: (drawioData) => void,
+};
+
+export const DrawioModal = (props: Props): JSX.Element => {
+  const { data: drawioUri } = useDrawioUri();
+  const { data: personalSettingsInfo } = usePersonalSettings();
+
+  const { data: drawioModalData, close: closeDrawioModal } = useDrawioModal();
+  const isOpened = drawioModalData?.isOpened ?? false;
+
+  const drawioUriWithParams = useMemo(() => {
+    if (drawioUri == null) {
+      return undefined;
+    }
+
+    const url = new URL(drawioUri);
+
+    // refs: https://desk.draw.io/support/solutions/articles/16000042546-what-url-parameters-are-supported-
+    url.searchParams.append('spin', '1');
+    url.searchParams.append('embed', '1');
+    url.searchParams.append('lang', getDiagramsNetLangCode(personalSettingsInfo?.lang || 'en'));
+    url.searchParams.append('ui', 'atlas');
+    url.searchParams.append('configure', '1');
+
+    return url;
+  }, [drawioUri, personalSettingsInfo?.lang]);
+
+  const drawioCommunicationHelper = useMemo(() => {
+    if (drawioUri == null) {
+      return undefined;
+    }
+
+    return new DrawioCommunicationHelper(
+      drawioUri,
+      drawioConfig,
+      { onClose: closeDrawioModal },
+    );
+  }, [closeDrawioModal, drawioUri]);
+
+  const receiveMessageHandler = useCallback((event: MessageEvent) => {
+    if (drawioModalData == null) {
+      return;
+    }
+
+    drawioCommunicationHelper?.onReceiveMessage(event, drawioModalData.drawioMxFile);
+  }, [drawioCommunicationHelper, drawioModalData]);
+
+  useEffect(() => {
+    if (isOpened) {
+      window.addEventListener('message', receiveMessageHandler);
+    }
+    else {
+      window.removeEventListener('message', receiveMessageHandler);
+    }
+
+    // clean up
+    return function() {
+      window.removeEventListener('message', receiveMessageHandler);
+    };
+  }, [isOpened, receiveMessageHandler]);
+
+  return (
+    <Modal
+      isOpen={isOpened}
+      toggle={() => closeDrawioModal()}
+      backdrop="static"
+      className="drawio-modal grw-body-only-modal-expanded"
+      size="xl"
+      keyboard={false}
+    >
+      <ModalBody className="p-0">
+        {/* Loading spinner */}
+        <div className="w-100 h-100 position-absolute d-flex">
+          <div className="mx-auto my-auto">
+            <i className="fa fa-3x fa-spinner fa-pulse mx-auto text-muted"></i>
+          </div>
+        </div>
+        {/* iframe */}
+        { drawioUriWithParams != null && (
+          <div className="w-100 h-100 position-absolute d-flex">
+            { isOpened && (
+              <iframe
+                src={drawioUriWithParams.href}
+                className="border-0 flex-grow-1"
+              >
+              </iframe>
+            ) }
+          </div>
+        ) }
+      </ModalBody>
+    </Modal>
+  );
+};

+ 9 - 3
packages/app/src/components/PageEditor/Editor.tsx

@@ -9,12 +9,14 @@ import {
 } from 'reactstrap';
 
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
+import { IEditorSettings } from '~/interfaces/editor-settings';
 import { useDefaultIndentSize } from '~/stores/context';
 import { useEditorSettings } from '~/stores/editor';
 import { useIsMobile } from '~/stores/ui';
 
 import { IEditorMethods } from '../../interfaces/editor-methods';
 
+import AbstractEditor from './AbstractEditor';
 import Cheatsheet from './Cheatsheet';
 import CodeMirrorEditor from './CodeMirrorEditor';
 import pasteHelper from './PasteHelper';
@@ -31,8 +33,12 @@ export type EditorPropsType = {
   isTextlintEnabled?: boolean,
   onChange?: (newValue: string, isClean?: boolean) => void,
   onUpload?: (file) => void,
+  editorSettings?: IEditorSettings,
   indentSize?: number,
-  onScroll?: ({ line: number }) => void,
+  onDragEnter?: (event: any) => void,
+  onMarkdownHelpButtonClicked?: () => void,
+  onAddAttachmentButtonClicked?: () => void,
+  onScroll?: (line: { line: number }) => void,
   onScrollCursorIntoView?: (line: number) => void,
   onSave?: () => Promise<void>,
   onPasteFiles?: (event: Event) => void,
@@ -59,7 +65,8 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
   const { data: isMobile } = useIsMobile();
 
   const dropzoneRef = useRef<DropzoneRef>(null);
-  const cmEditorRef = useRef<CodeMirrorEditor>(null);
+  // CodeMirrorEditor ref
+  const cmEditorRef = useRef<AbstractEditor<any>>(null);
   const taEditorRef = useRef<TextAreaEditor>(null);
 
   const editorSubstance = useCallback(() => {
@@ -285,7 +292,6 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
 
                 {/* for PC */}
                 { !isMobile && (
-                  // eslint-disable-next-line arrow-body-style
                   <CodeMirrorEditor
                     ref={cmEditorRef}
                     indentSize={indentSize ?? defaultIndentSize}

+ 9 - 13
packages/app/src/components/PageEditor/EmojiPicker.tsx

@@ -1,4 +1,4 @@
-import React, { FC } from 'react';
+import React, { FC, useCallback } from 'react';
 
 import { Picker } from 'emoji-mart';
 import { Modal } from 'reactstrap';
@@ -9,6 +9,7 @@ import EmojiPickerHelper, { getEmojiTranslation } from './EmojiPickerHelper';
 
 type Props = {
   onClose: () => void,
+  onSelected: (emoji: string) => void,
   emojiSearchText: string,
   emojiPickerHelper: EmojiPickerHelper,
   isOpen: boolean
@@ -17,30 +18,25 @@ type Props = {
 const EmojiPicker: FC<Props> = (props: Props) => {
 
   const {
-    onClose, emojiSearchText, emojiPickerHelper, isOpen,
+    onClose, onSelected, emojiSearchText, emojiPickerHelper, isOpen,
   } = props;
 
   const { resolvedTheme } = useNextThemes();
 
   // Set search emoji input and trigger search
-  const searchEmoji = () => {
+  const searchEmoji = useCallback(() => {
     const input = window.document.querySelector('[id^="emoji-mart-search"]') as HTMLInputElement;
     const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
     valueSetter?.call(input, emojiSearchText);
     const event = new Event('input', { bubbles: true });
     input.dispatchEvent(event);
     input.focus();
-  };
-
-  const selectEmoji = (emoji) => {
-    if (emojiSearchText !== null) {
-      emojiPickerHelper.addEmojiOnSearch(emoji);
-    }
-    else {
-      emojiPickerHelper.addEmoji(emoji);
-    }
+  }, [emojiSearchText]);
+
+  const selectEmoji = useCallback((emoji) => {
+    onSelected(emoji);
     onClose();
-  };
+  }, [onClose, onSelected]);
 
 
   const translation = getEmojiTranslation();

+ 28 - 39
packages/app/src/components/PageEditor/EmojiPickerHelper.ts

@@ -1,22 +1,22 @@
 import { CSSProperties } from 'react';
 
+import { Position } from 'codemirror';
 import i18n from 'i18next';
 
-// https://regex101.com/r/Gqhor8/1
-const EMOJI_PATTERN = new RegExp(/\B:[^:\s]+/);
+// https://regex101.com/r/x5LbOZ/1
+const EMOJI_PATTERN = new RegExp(/^:[a-z0-9-+_]+$/);
 
 export default class EmojiPickerHelper {
 
   editor;
 
-  pattern: RegExp;
+  pattern: string;
 
   constructor(editor) {
     this.editor = editor;
-    this.pattern = EMOJI_PATTERN;
   }
 
-  setStyle = ():CSSProperties => {
+  setStyle = (): CSSProperties => {
     const offset = 20;
     const emojiPickerHeight = 420;
     const cursorPos = this.editor.cursorCoords(true);
@@ -36,53 +36,42 @@ export default class EmojiPickerHelper {
     };
   };
 
-  getSearchCursor = () => {
-    const currentPos = this.editor.getCursor();
-    const sc = this.editor.getSearchCursor(this.pattern, currentPos, { multiline: false });
-    return sc;
-  };
+  shouldModeTurnOn = (char: string): Position | null | undefined => {
+    if (char !== ':') {
+      return null;
+    }
 
-  // Add emoji when triggered by search
-  addEmojiOnSearch = (emoji) => {
     const currentPos = this.editor.getCursor();
-    const sc = this.getSearchCursor();
+    const sc = this.editor.getSearchCursor(':', currentPos, { multiline: false });
     if (sc.findPrevious()) {
-      sc.replace(`${emoji.colons} `, this.editor.getTokenAt(currentPos).string);
-      this.editor.focus();
-      this.editor.refresh();
+      return sc.pos.from;
     }
   };
 
+  shouldOpen = (startPos: Position): boolean => {
+    const currentPos = this.editor.getCursor();
+    const rangeStr = this.editor.getRange(startPos, currentPos);
+
+    return EMOJI_PATTERN.test(rangeStr);
+  };
 
-  // Add emoji when triggered by click emoji icon on top of editor
-  addEmoji = (emoji) => {
+  getInitialSearchingText = (startPos: Position): void => {
     const currentPos = this.editor.getCursor();
-    const doc = this.editor.getDoc();
-    doc.replaceRange(`${emoji.colons} `, currentPos);
-    this.editor.focus();
-    this.editor.refresh();
+    const rangeStr = this.editor.getRange(startPos, currentPos);
+
+    return rangeStr.slice(1); // return without the heading ':'
   };
 
-  getEmoji = () => {
-    const sc = this.getSearchCursor();
+  addEmoji = (emoji: { colons: string }, startPosToReplace: Position|null): void => {
     const currentPos = this.editor.getCursor();
 
-    if (sc.findPrevious()) {
-      const isInputtingEmoji = (currentPos.line === sc.to().line && currentPos.ch === sc.to().ch);
-      // current search cursor position
-      if (!isInputtingEmoji) {
-        return;
-      }
-      const pos = {
-        line: sc.to().line,
-        ch: sc.to().ch,
-      };
-      const currentSearchText = sc.matches(true, pos).match[0];
-      const searchWord = currentSearchText.replace(':', '');
-      return searchWord;
-    }
+    const from = startPosToReplace ?? currentPos;
+    const to = currentPos;
 
-    return;
+    const doc = this.editor.getDoc();
+    doc.replaceRange(`${emoji.colons} `, from, to);
+    this.editor.focus();
+    this.editor.refresh();
   };
 
 }

+ 80 - 48
packages/app/src/components/PageEditorByHackmd.tsx

@@ -15,8 +15,10 @@ import { IResHackmdIntegrated, IResHackmdDiscard } from '~/interfaces/hackmd';
 import {
   useCurrentPagePath, useCurrentPageId, useHackmdUri, usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced,
 } from '~/stores/context';
-import { useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors } from '~/stores/editor';
-import { useSWRxCurrentPage } from '~/stores/page';
+import {
+  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
+} from '~/stores/editor';
+import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import {
   EditorMode,
   useEditorMode, useSelectedGrant,
@@ -42,12 +44,13 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: pageId } = useCurrentPageId();
-  const { data: pageTags, mutate: updatePageTagsForEditors } = usePageTagsForEditors(pageId);
+  const { data: pageTags } = usePageTagsForEditors(pageId);
+  const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { data: grant } = useSelectedGrant();
   const { data: hackmdUri } = useHackmdUri();
 
   // pageData
-  const { data: pageData, mutate: updatePageData } = useSWRxCurrentPage();
+  const { data: pageData, mutate: mutatePageData } = useSWRxCurrentPage();
   const revision = pageData?.revision;
 
   const slackChannels = slackChannelsData?.toString();
@@ -63,41 +66,61 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: pageIdOnHackmd, mutate: mutatePageIdOnHackmd } = usePageIdOnHackmd();
   const { data: hasDraftOnHackmd, mutate: mutateHasDraftOnHackmd } = useHasDraftOnHackmd();
   const { data: revisionIdHackmdSynced, mutate: mutateRevisionIdHackmdSynced } = useRevisionIdHackmdSynced();
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const [isHackmdDraftUpdatingInRealtime, setIsHackmdDraftUpdatingInRealtime] = useState(false);
   const [remoteRevisionId, setRemoteRevisionId] = useState(revision?._id); // initialize
 
   const hackmdEditorRef = useRef<HackEditorRef>(null);
 
   const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
-    if (editorMode !== EditorMode.HackMD) {
-      return;
-    }
+    if (editorMode !== EditorMode.HackMD) { return }
 
-    if (isSlackEnabled == null || currentPathname == null || slackChannels == null || grant == null || revision == null || hackmdEditorRef.current == null) {
-      return;
-    }
+    try {
+      if (isSlackEnabled == null || currentPathname == null || slackChannels == null || grant == null || revision == null || hackmdEditorRef.current == null) {
+        throw new Error('Some materials to save are invalid');
+      }
 
-    let optionsToSave;
+      let optionsToSave;
 
-    const currentOptionsToSave = getOptionsToSave(
-      isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
-    );
+      const currentOptionsToSave = getOptionsToSave(
+        isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
+      );
 
-    if (opts != null) {
-      optionsToSave = Object.assign(currentOptionsToSave, {
-        ...opts,
-      });
-    }
-    else {
-      optionsToSave = currentOptionsToSave;
-    }
+      if (opts != null) {
+        optionsToSave = Object.assign(currentOptionsToSave, {
+          ...opts,
+        });
+      }
+      else {
+        optionsToSave = currentOptionsToSave;
+      }
 
-    const markdown = await hackmdEditorRef.current.getValue();
+      const markdown = await hackmdEditorRef.current.getValue();
 
-    await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revision?._id }, markdown);
-    await updatePageData();
-    mutateEditorMode(EditorMode.View);
-  }, [currentPagePath, currentPathname, editorMode, grant, isSlackEnabled, pageId, pageTags, revision, slackChannels, mutateEditorMode, updatePageData]);
+      await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revision?._id }, markdown);
+      await mutatePageData();
+      await mutateTagsInfo();
+      mutateEditorMode(EditorMode.View);
+      mutateIsEnabledUnsavedWarning(false);
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      toastError(error.message);
+    }
+  }, [editorMode,
+      isSlackEnabled,
+      currentPathname,
+      slackChannels,
+      grant,
+      revision,
+      pageTags,
+      pageId,
+      currentPagePath,
+      mutatePageData,
+      mutateEditorMode,
+      mutateTagsInfo,
+      mutateIsEnabledUnsavedWarning,
+  ]);
 
   // set handler to save and reload Page
   useEffect(() => {
@@ -134,7 +157,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       mutateRevisionIdHackmdSynced(res.revisionIdHackmdSynced);
     }
     catch (err) {
-      toastError(err);
+      toastError(err.message);
 
       setHasError(true);
       setErrorMessage('GROWI server failed to connect to HackMD.');
@@ -173,7 +196,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
     }
     catch (err) {
       logger.error(err);
-      toastError(err);
+      toastError(err.message);
     }
   }, [setIsHackmdDraftUpdatingInRealtime, mutateHasDraftOnHackmd, mutatePageIdOnHackmd, mutateRevisionIdHackmdSynced, pageId]);
 
@@ -182,27 +205,26 @@ export const PageEditorByHackmd = (): JSX.Element => {
    * @param {string} markdown
    */
   const onSaveWithShortcut = useCallback(async(markdown) => {
-    if (
-      isSlackEnabled == null || grant == null || slackChannels == null || pageId == null || revisionIdHackmdSynced == null || currentPathname == null
-    ) { return }
-    const optionsToSave = getOptionsToSave(
-      isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
-    );
-
     try {
-      const res = await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revisionIdHackmdSynced }, markdown);
+      const currentPagePathOrPathname = currentPagePath || currentPathname;
+      if (
+        isSlackEnabled == null || grant == null || slackChannels == null || pageId == null
+        || revisionIdHackmdSynced == null || currentPagePathOrPathname == null
+      ) { throw new Error('Some materials to save are invalid') }
+      const optionsToSave = getOptionsToSave(
+        isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
+      );
+      const res = await saveOrUpdate(optionsToSave, { pageId, path: currentPagePathOrPathname, revisionId: revisionIdHackmdSynced }, markdown);
 
       // update pageData
-      updatePageData();
+      mutatePageData(res);
 
       // set updated data
       setRemoteRevisionId(res.revision._id);
       mutateRevisionIdHackmdSynced(res.page.revisionHackmdSynced);
       mutateHasDraftOnHackmd(res.page.hasDraftOnHackmd);
-      updatePageTagsForEditors(res.tags);
-
-      // call reset
-      setIsInitialized(false);
+      mutateTagsInfo();
+      mutateIsEnabledUnsavedWarning(false);
 
       logger.debug('success to save');
 
@@ -210,12 +232,22 @@ export const PageEditorByHackmd = (): JSX.Element => {
     }
     catch (error) {
       logger.error('failed to save', error);
-      toastError(error);
+      toastError(error.message);
     }
-  }, [
-    grant, isSlackEnabled, pageTags, slackChannels, updatePageTagsForEditors, pageId, currentPagePath, currentPathname,
-    revisionIdHackmdSynced, updatePageData, mutateHasDraftOnHackmd, mutateRevisionIdHackmdSynced, t,
-  ]);
+  }, [isSlackEnabled,
+      grant,
+      slackChannels,
+      pageId,
+      revisionIdHackmdSynced,
+      currentPathname,
+      pageTags,
+      currentPagePath,
+      mutatePageData,
+      mutateRevisionIdHackmdSynced,
+      mutateHasDraftOnHackmd,
+      mutateTagsInfo,
+      mutateIsEnabledUnsavedWarning,
+      t]);
 
   /**
    * onChange event of HackmdEditor handler
@@ -240,7 +272,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
   }, [pageId, revision?.body, hackmdUri]);
 
   const penpalErrorOccuredHandler = useCallback((error) => {
-    toastError(error);
+    toastError(error.message);
 
     setHasError(true);
     setErrorMessage(t('hackmd.fail_to_connect'));

+ 1 - 0
packages/app/src/components/PageList/PageListItemL.tsx

@@ -164,6 +164,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     <li
       key={pageData._id}
       className={`list-group-item d-flex align-items-center px-3 px-md-1 ${styleListGroupItem} ${styleActive}`}
+      data-testid="page-list-item-L"
       onClick={clickHandler}
     >
       <div className="text-break w-100">

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

@@ -8,7 +8,7 @@ import { IPageHasId } from '~/interfaces/page';
 import { useCurrentPagePath } from '~/stores/context';
 import { useTimelineOptions } from '~/stores/renderer';
 
-import RevisionLoader from './Page/RevisionLoader';
+import { RevisionLoader } from './Page/RevisionLoader';
 import PaginationWrapper from './PaginationWrapper';
 
 import styles from './PageTimeline.module.scss';

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

@@ -74,7 +74,7 @@ const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.
 
   if (isSuccess) {
     return (
-      <div className="card border-success mt-3">
+      <div className="card border-success mt-3" data-testid="search-result-private-legacy-pages">
         <div className="card-body">
           <h2 className="card-title text-success">{t('private_legacy_pages.nopages_title')}</h2>
           <p className="card-text">

+ 2 - 4
packages/app/src/components/ReactMarkdownComponents/CodeBlock.module.scss

@@ -1,11 +1,9 @@
 @use '~/styles/variables' as var;
 @use '~/styles/bootstrap/init' as bs;
+@use '~/styles/atoms/mixins' as atm;
 
 .code-inline {
-  padding: 2px 4px;
-  font-family: var.$font-family-monospace-not-strictly;
-  border: 1px solid;
-  border-radius: bs.$border-radius;
+  @include atm.code-inline;
 }
 
 .code-highlighted-title {

+ 3 - 9
packages/app/src/components/SavePageControls.tsx

@@ -36,7 +36,6 @@ export const SavePageControls = (props: Props): JSX.Element | null => {
   const { data: isAclEnabled } = useIsAclEnabled();
   const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: pageId } = useCurrentPageId();
-  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
 
 
   const updateGrantHandler = useCallback((grantData: IPageGrantData): void => {
@@ -44,19 +43,14 @@ export const SavePageControls = (props: Props): JSX.Element | null => {
   }, [mutateGrant]);
 
   const save = useCallback(async(): Promise<void> => {
-    // disable unsaved warning
-    mutateIsEnabledUnsavedWarning(false);
-
     // save
     (window as CustomWindow).globalEmitter.emit('saveAndReturnToView');
-  }, [mutateIsEnabledUnsavedWarning]);
+  }, []);
 
   const saveAndOverwriteScopesOfDescendants = useCallback(() => {
-    // disable unsaved warning
-    mutateIsEnabledUnsavedWarning(false);
     // save
     (window as CustomWindow).globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true });
-  }, [mutateIsEnabledUnsavedWarning]);
+  }, []);
 
 
   if (isEditable == null || isAclEnabled == null || grantData == null) {
@@ -91,7 +85,7 @@ export const SavePageControls = (props: Props): JSX.Element | null => {
       }
 
       <UncontrolledButtonDropdown direction="up">
-        <Button id="caret" color="primary" className="btn-submit" onClick={save}>
+        <Button data-testid="save-page-btn" id="caret" color="primary" className="btn-submit" onClick={save}>
           {labelSubmitButton}
         </Button>
         <DropdownToggle caret color="primary" />

+ 3 - 1
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -24,12 +24,14 @@ import { useFullTextSearchTermManager } from '~/stores/search';
 import { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import { GrowiSubNavigationProps } from '../Navbar/GrowiSubNavigation';
 import { SubNavButtonsProps } from '../Navbar/SubNavButtons';
+import { RevisionLoaderProps } from '../Page/RevisionLoader';
 import { PageCommentProps } from '../PageComment';
 import { PageContentFooterProps } from '../PageContentFooter';
 
+
 const GrowiSubNavigation = dynamic<GrowiSubNavigationProps>(() => import('../Navbar/GrowiSubNavigation').then(mod => mod.GrowiSubNavigation), { ssr: false });
 const SubNavButtons = dynamic<SubNavButtonsProps>(() => import('../Navbar/SubNavButtons').then(mod => mod.SubNavButtons), { ssr: false });
-const RevisionLoader = dynamic(() => import('../Page/RevisionLoader'), { ssr: false });
+const RevisionLoader = dynamic<RevisionLoaderProps>(() => import('../Page/RevisionLoader').then(mod => mod.RevisionLoader), { ssr: false });
 const PageComment = dynamic<PageCommentProps>(() => import('../PageComment').then(mod => mod.PageComment), { ssr: false });
 const PageContentFooter = dynamic<PageContentFooterProps>(() => import('../PageContentFooter').then(mod => mod.PageContentFooter), { ssr: false });
 

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

@@ -36,7 +36,7 @@ const PageItemLower = memo(({ page }: PageItemProps): JSX.Element => {
         <div className="icon-bubble mr-1 d-inline-block"></div>
         <div className="mr-2 grw-list-counts d-inline-block">{page.commentCount}</div>
       </div>
-      <div className="grw-formatted-distance-date small mt-auto">
+      <div className="grw-formatted-distance-date small mt-auto" data-hide-in-vrt>
         <FormattedDistanceDate id={page._id} date={page.updatedAt} />
       </div>
     </div>

+ 16 - 12
packages/app/src/components/User/UserInfo.tsx

@@ -1,37 +1,41 @@
 import React from 'react';
 
+import { IUserHasId } from '@growi/core';
 import { UserPicture } from '@growi/ui';
 
-import { usePageUser } from '~/stores/context';
-
 import styles from './UserInfo.module.scss';
 
-export const UserInfo = (): JSX.Element => {
 
-  const { data: pageUser } = usePageUser();
+export type UserInfoProps = {
+  author?: IUserHasId,
+}
+
+export const UserInfo = (props: UserInfoProps): JSX.Element => {
+
+  const { author } = props;
 
-  if (pageUser == null || pageUser.status === 4) {
+  if (author == null || author.status === 4) {
     return <></>;
   }
 
   return (
     <div className={`${styles['grw-users-info']} grw-users-info d-flex align-items-center d-edit-none mb-5 pb-3 border-bottom`}>
-      <UserPicture user={pageUser} />
+      <UserPicture user={author} />
       <div className="users-meta">
         <h1 className="user-page-name">
-          {pageUser.name}
+          {author.name}
         </h1>
         <div className="user-page-meta mt-3 mb-0">
-          <span className="user-page-username mr-4"><i className="icon-user mr-1"></i>{pageUser.username}</span>
+          <span className="user-page-username mr-4"><i className="icon-user mr-1"></i>{author.username}</span>
           <span className="user-page-email mr-2">
             <i className="icon-envelope mr-1"></i>
-            { pageUser.isEmailPublished
-              ? pageUser.email
+            { author.isEmailPublished
+              ? author.email
               : '*****'
             }
           </span>
-          { pageUser.introduction && (
-            <span className="user-page-introduction">{pageUser.introduction}</span>
+          { author.introduction && (
+            <span className="user-page-introduction">{author.introduction}</span>
           ) }
         </div>
       </div>

+ 1 - 1
packages/app/src/interfaces/activity.ts

@@ -67,7 +67,7 @@ const ACTION_SHARE_LINK_EXPIRED_PAGE_VIEW = 'SHARE_LINK_EXPIRED_PAGE_VIEW';
 const ACTION_SHARE_LINK_NOT_FOUND = 'SHARE_LINK_NOT_FOUND';
 const ACTION_ATTACHMENT_ADD = 'ATTACHMENT_ADD';
 const ACTION_ATTACHMENT_REMOVE = 'ATTACHMENT_REMOVE';
-const ACTION_ATTACHMENT_DOWNLOAD = 'ACTION_ATTACHMENT_DOWNLOAD';
+const ACTION_ATTACHMENT_DOWNLOAD = 'ATTACHMENT_DOWNLOAD';
 const ACTION_SEARCH_PAGE = 'SEARCH_PAGE';
 const ACTION_SEARCH_PAGE_VIEW = 'SEARCH_PAGE_VIEW';
 const ACTION_ADMIN_APP_SETTINGS_UPDATE = 'ADMIN_APP_SETTING_UPDATE';

+ 5 - 0
packages/app/src/interfaces/errors/login-error.ts

@@ -0,0 +1,5 @@
+export const LoginErrorCode = {
+  PROVIDER_DUPLICATED_USERNAME_EXCEPTION: 'provider-duplicated-username-exception',
+} as const;
+
+export type LoginErrorCode = typeof LoginErrorCode[keyof typeof LoginErrorCode];

+ 3 - 0
packages/app/src/interfaces/errors/v3-error.ts

@@ -0,0 +1,3 @@
+import { ErrorV3 } from '@growi/core';
+
+export type IErrorV3 = ErrorV3

+ 0 - 7
packages/app/src/interfaces/services/renderer.ts

@@ -1,12 +1,5 @@
 import { XssOptionConfig } from '~/services/xss/xssOption';
 
-// export type GrowiHydratedEnv = {
-//   DRAWIO_URI: string | null,
-//   HACKMD_URI: string | null,
-//   NO_CDN: string | null,
-//   GROWI_CLOUD_URI: string | null,
-//   GROWI_APP_ID_FOR_GROWI_CLOUD: string | null,
-// }
 
 export type RendererConfig = {
   isEnabledLinebreaks: boolean,

+ 19 - 21
packages/app/src/pages/[[...path]].page.tsx

@@ -58,11 +58,11 @@ import {
   useIsForbidden, useIsNotFound, useIsTrashPage, useIsSharedUser,
   useIsEnabledStaleNotification, useIsIdenticalPath,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useDisableLinkSharing,
-  useHackmdUri,
-  useIsAclEnabled, useIsUserPage,
+  useDrawioUri, useHackmdUri,
+  useIsAclEnabled, useIsUserPage, useIsSearchPage,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
   useIsSlackConfigured, useRendererConfig, useEditingMarkdown,
-  useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, usePageUser,
+  useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage,
 } from '../stores/context';
 
 import {
@@ -126,7 +126,7 @@ const PutbackPageModal = (): JSX.Element => {
 type Props = CommonProps & {
   currentUser: IUser,
 
-  pageWithMeta: IPageToShowRevisionWithMeta,
+  pageWithMeta: IPageToShowRevisionWithMeta | null,
   // pageUser?: any,
   redirectFrom?: string;
 
@@ -147,9 +147,9 @@ type Props = CommonProps & {
   // isMailerSetup: boolean,
   isAclEnabled: boolean,
   // hasSlackConfig: boolean,
-  // drawioUri: string,
+  drawioUri: string,
   hackmdUri: string,
-  // noCdn: string,
+  noCdn: string,
   // highlightJsStyle: string,
   isAllReplyShown: boolean,
   // isContainerFluid: boolean,
@@ -204,6 +204,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   useIsIdenticalPath(false); // TODO: need to initialize from props
   // useIsAbleToDeleteCompletely(props.isAbleToDeleteCompletely);
   useIsEnabledStaleNotification(props.isEnabledStaleNotification);
+  useIsSearchPage(false);
 
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
@@ -213,12 +214,11 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   // useIsMailerSetup(props.isMailerSetup);
   useIsAclEnabled(props.isAclEnabled);
   // useHasSlackConfig(props.hasSlackConfig);
-  // useDrawioUri(props.drawioUri);
+  useDrawioUri(props.drawioUri);
   useHackmdUri(props.hackmdUri);
   // useNoCdn(props.noCdn);
   // useIndentSize(props.adminPreferredIndentSize);
   useDisableLinkSharing(props.disableLinkSharing);
-
   useRendererConfig(props.rendererConfig);
   // useRendererSettings(props.rendererSettingsStr != null ? JSON.parse(props.rendererSettingsStr) : undefined);
   // useGrowiRendererConfig(props.growiRendererConfigStr != null ? JSON.parse(props.growiRendererConfigStr) : undefined);
@@ -231,27 +231,25 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
 
   const { pageWithMeta, userUISettings } = props;
 
-  let shouldRenderPutbackPageModal = false;
-  if (pageWithMeta != null) {
-    shouldRenderPutbackPageModal = _isTrashPage(pageWithMeta.data.path);
-  }
+  useSWRxCurrentPage(undefined, pageWithMeta?.data ?? null); // store initial data
+  useEditingMarkdown(pageWithMeta?.data.revision?.body ?? '');
 
   const pageId = pageWithMeta?.data._id;
   const pagePath = pageWithMeta?.data.path ?? (!_isPermalink(props.currentPathname) ? props.currentPathname : undefined);
 
-  useCurrentPageId(pageId);
-  useSWRxCurrentPage(undefined, pageWithMeta?.data); // store initial data
+  useCurrentPageId(pageId ?? null);
   useIsUserPage(pagePath != null && isUserPage(pagePath));
   // useIsNotCreatable(props.isForbidden || !isCreatablePage(pagePath)); // TODO: need to include props.isIdentical
   useCurrentPagePath(pagePath);
   useCurrentPathname(props.currentPathname);
-  useEditingMarkdown(pageWithMeta?.data.revision?.body);
   useIsTrashPage(pagePath != null && _isTrashPage(pagePath));
 
   const { data: grantData } = useSWRxIsGrantNormalized(pageId);
   const { mutate: mutateSelectedGrant } = useSelectedGrant();
 
-  usePageUser(pageWithMeta?.data.creator);
+  const shouldRenderPutbackPageModal = pageWithMeta != null
+    ? _isTrashPage(pageWithMeta.data.path)
+    : false;
 
   // sync grant data
   useEffect(() => {
@@ -329,8 +327,8 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
           </div>
           { !props.isIdenticalPathPage && !props.isNotFound && (
             <footer className="footer d-edit-none">
-              { !isTopPagePath && (<Comments pageId={pageId} revision={pageWithMeta?.data.revision} />) }
-              { (pageWithMeta != null && isUsersHomePage(pageWithMeta.data.path)) && (
+              { pageWithMeta != null && !isTopPagePath && (<Comments pageId={pageId} revision={pageWithMeta.data.revision} />) }
+              { pageWithMeta != null && isUsersHomePage(pageWithMeta.data.path) && (
                 <UsersHomePageFooter creatorId={pageWithMeta.data.creator._id}/>
               ) }
               <CurrentPageContentFooter />
@@ -396,7 +394,7 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
     }
   }
 
-  const pageWithMeta: IPageToShowRevisionWithMeta = await pageService.findPageAndMetaDataByViewer(pageId, currentPathname, user, true); // includeEmpty = true, isSharedPage = false
+  const pageWithMeta: IPageToShowRevisionWithMeta | null = await pageService.findPageAndMetaDataByViewer(pageId, currentPathname, user, true); // includeEmpty = true, isSharedPage = false
   const page = pageWithMeta?.data as unknown as PageDocument;
 
   // add user to seen users
@@ -493,9 +491,9 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   // props.isMailerSetup = mailService.isMailerSetup;
   props.isAclEnabled = aclService.isAclEnabled();
   // props.hasSlackConfig = slackNotificationService.hasSlackConfig();
-  // props.drawioUri = configManager.getConfig('crowi', 'app:drawioUri');
+  props.drawioUri = configManager.getConfig('crowi', 'app:drawioUri');
   props.hackmdUri = configManager.getConfig('crowi', 'app:hackmdUri');
-  // props.noCdn = configManager.getConfig('crowi', 'app:noCdn');
+  props.noCdn = configManager.getConfig('crowi', 'app:noCdn');
   // props.highlightJsStyle = configManager.getConfig('crowi', 'customize:highlightJsStyle');
   props.isAllReplyShown = configManager.getConfig('crowi', 'customize:isAllReplyShown');
   // props.isContainerFluid = configManager.getConfig('crowi', 'customize:isContainerFluid');

+ 17 - 0
packages/app/src/pages/_error.page.tsx

@@ -0,0 +1,17 @@
+import { NextPageContext } from 'next';
+import Error, { ErrorProps } from 'next/error';
+
+export default function ErrorPage(props: ErrorProps): JSX.Element {
+  return <Error {...props} />;
+}
+
+// add getInitialProps to disable "https://nextjs.org/docs/messages/prerender-error"
+//   Error: Export encountered errors on following paths:
+//     /_error: /404
+//     /_error: /500
+// see: https://github.com/vercel/next.js/issues/23568#issuecomment-814971318
+ErrorPage.getInitialProps = (ctx: NextPageContext) => {
+  const { res, err } = ctx;
+  const statusCode = res?.statusCode ?? err?.statusCode ?? 500;
+  return { statusCode };
+};

+ 8 - 3
packages/app/src/pages/admin/[[...path]].page.tsx

@@ -34,12 +34,12 @@ import PluginUtils from '~/server/plugins/plugin-utils';
 import ConfigLoader from '~/server/service/config-loader';
 import {
   useCurrentUser, /* useSearchServiceConfigured, */ useIsAclEnabled, useIsMailerSetup, useIsSearchServiceReachable, useSiteUrl,
-  useAuditLogEnabled, useAuditLogAvailableActions,
+  useAuditLogEnabled, useAuditLogAvailableActions, useIsSearchPage, useCustomizeTitle,
 } from '~/stores/context';
 import { useIsMaintenanceMode } from '~/stores/maintenanceMode';
 
 import {
-  CommonProps, getServerSideCommonProps, getNextI18NextConfig,
+  CommonProps, getServerSideCommonProps, getNextI18NextConfig, useCustomTitle,
 } from '../utils/commons';
 
 
@@ -83,6 +83,7 @@ type Props = CommonProps & {
   auditLogEnabled: boolean,
   auditLogAvailableActions: SupportedActionType[],
 
+  customizeTitle: string,
   siteUrl: string,
 };
 
@@ -196,6 +197,7 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
 
   const targetPage = getTargetPageToRender(adminPagesMap, pagePathKeys);
 
+  useIsSearchPage(false);
   useCurrentUser(props.currentUser != null ? JSON.parse(props.currentUser) : null);
   useIsMailerSetup(props.isMailerSetup);
   useIsMaintenanceMode(props.isMaintenanceMode);
@@ -211,6 +213,8 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
   useAuditLogEnabled(props.auditLogEnabled);
   useAuditLogAvailableActions(props.auditLogAvailableActions);
 
+  useCustomizeTitle(props.customizeTitle);
+
   const injectableContainers: Container<any>[] = [];
 
   if (isClient()) {
@@ -273,7 +277,7 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
 
   return (
     <Provider inject={[...injectableContainers, ...adminSecurityContainers]}>
-      <AdminLayout title={targetPage.title} selectedNavOpt={firstPath}>
+      <AdminLayout title={useCustomTitle(props, targetPage.title)} selectedNavOpt={firstPath} componentTitle={targetPage.title}>
         {targetPage.component}
       </AdminLayout>
     </Provider>
@@ -303,6 +307,7 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
 
   props.auditLogEnabled = crowi.configManager.getConfig('crowi', 'app:auditLogEnabled');
   props.auditLogAvailableActions = activityService.getAvailableActions(false);
+  props.customizeTitle = crowi.configManager.getConfig('crowi', 'customize:title');
 }
 
 /**

+ 10 - 2
packages/app/src/pages/login.page.tsx

@@ -26,6 +26,9 @@ type Props = CommonProps & {
   isMailerSetup: boolean,
   enabledStrategies: unknown,
   registrationWhiteList: string[],
+  isLocalStrategySetup: boolean,
+  isLdapStrategySetup: boolean,
+  isLdapSetupFailed: boolean,
 };
 
 const LoginPage: NextPage<Props> = (props: Props) => {
@@ -43,8 +46,9 @@ const LoginPage: NextPage<Props> = (props: Props) => {
       <LoginForm
         // Todo: These props should be set properly. https://redmine.weseek.co.jp/issues/104847
         objOfIsExternalAuthEnableds={props.enabledStrategies}
-        isLocalStrategySetup={true}
-        isLdapStrategySetup={true}
+        isLocalStrategySetup={props.isLocalStrategySetup}
+        isLdapStrategySetup={props.isLdapStrategySetup}
+        isLdapSetupFailed={props.isLdapSetupFailed}
         isEmailAuthenticationEnabled={false}
         isRegistrationEnabled={true}
         registrationWhiteList={props.registrationWhiteList}
@@ -92,9 +96,13 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
   const {
     mailService,
     configManager,
+    passportService,
   } = crowi;
 
   props.isMailerSetup = mailService.isMailerSetup;
+  props.isLocalStrategySetup = passportService.isLocalStrategySetup;
+  props.isLdapStrategySetup = passportService.isLdapStrategySetup;
+  props.isLdapSetupFailed = configManager.getConfig('crowi', 'security:passport-ldap:isEnabled') && !props.isLdapStrategySetup;
   props.registrationWhiteList = configManager.getConfig('crowi', 'security:registrationWhiteList');
 }
 

+ 3 - 1
packages/app/src/pages/me/[[...path]].page.tsx

@@ -18,7 +18,7 @@ import { ISidebarConfig } from '~/interfaces/sidebar-config';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
 import { UserUISettingsModel } from '~/server/models/user-ui-settings';
 import {
-  useCurrentUser,
+  useCurrentUser, useIsSearchPage,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useCsrfToken, useIsSearchScopeChildrenAsDefault,
   useRegistrationWhiteList, useShowPageLimitationXL,
@@ -85,6 +85,8 @@ const MePage: NextPage<Props> = (props: Props) => {
 
   const targetPage = getTargetPageToRender(mePagesMap, pagePathKeys);
 
+  useIsSearchPage(false);
+
   useCurrentUser(props.currentUser ?? null);
 
   useRegistrationWhiteList(props.registrationWhiteList);

+ 41 - 6
packages/app/src/pages/share/[[...path]].page.tsx

@@ -10,18 +10,22 @@ import dynamic from 'next/dynamic';
 import { ShareLinkLayout } from '~/components/Layout/ShareLinkLayout';
 import GrowiContextualSubNavigation from '~/components/Navbar/GrowiContextualSubNavigation';
 import { Page } from '~/components/Page';
+import { SupportedAction, SupportedActionType } from '~/interfaces/activity';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { RendererConfig } from '~/interfaces/services/renderer';
 import { IShareLinkHasId } from '~/interfaces/share-link';
 import {
-  useCurrentUser, useCurrentPagePath, useCurrentPathname, useCurrentPageId, useRendererConfig,
+  useCurrentUser, useCurrentPagePath, useCurrentPathname, useCurrentPageId, useRendererConfig, useIsSearchPage,
   useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault,
 } from '~/stores/context';
+import loggerFactory from '~/utils/logger';
 
 import {
   CommonProps, getServerSideCommonProps, useCustomTitle, getNextI18NextConfig,
 } from '../utils/commons';
 
+const logger = loggerFactory('growi:next-page:share');
+
 const ShareLinkAlert = dynamic(() => import('~/components/Page/ShareLinkAlert'), { ssr: false });
 const ForbiddenPage = dynamic(() => import('~/components/ForbiddenPage'), { ssr: false });
 
@@ -37,6 +41,7 @@ type Props = CommonProps & {
 };
 
 const SharedPage: NextPage<Props> = (props: Props) => {
+  useIsSearchPage(false);
   useShareLinkId(props.shareLink?._id);
   useCurrentPageId(props.shareLink?.relatedPage._id);
   useCurrentPagePath(props.shareLink?.relatedPage.path);
@@ -130,9 +135,40 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
   props._nextI18Next = nextI18NextConfig._nextI18Next;
 }
 
+function getAction(props: Props): SupportedActionType {
+  let action: SupportedActionType;
+  if (props.isExpired) {
+    action = SupportedAction.ACTION_SHARE_LINK_EXPIRED_PAGE_VIEW;
+  }
+  else if (props.shareLink == null) {
+    action = SupportedAction.ACTION_SHARE_LINK_NOT_FOUND;
+  }
+  else {
+    action = SupportedAction.ACTION_SHARE_LINK_PAGE_VIEW;
+  }
+
+  return action;
+}
+
+async function addActivity(context: GetServerSidePropsContext, action: SupportedActionType): Promise<void> {
+  const req: CrowiRequest = context.req as CrowiRequest;
+
+  const parameters = {
+    ip: req.ip,
+    endpoint: req.originalUrl,
+    action,
+    user: req.user?._id,
+    snapshot: {
+      username: req.user?.username,
+    },
+  };
+
+  await req.crowi.activityService.createActivity(parameters);
+}
+
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
   const req = context.req as CrowiRequest<IUserHasId & any>;
-  const { user, crowi } = req;
+  const { user, crowi, params } = req;
   const result = await getServerSideCommonProps(context);
 
   if (!('props' in result)) {
@@ -144,22 +180,21 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
     props.currentUser = user.toObject();
   }
 
-  const { linkId } = req.params;
   try {
     const ShareLinkModel = crowi.model('ShareLink');
-    const shareLink = await ShareLinkModel.findOne({ _id: linkId }).populate('relatedPage');
+    const shareLink = await ShareLinkModel.findOne({ _id: params.linkId }).populate('relatedPage');
     if (shareLink != null) {
       props.isExpired = shareLink.isExpired();
       props.shareLink = shareLink.toObject();
     }
   }
   catch (err) {
-    //
+    logger.error(err);
   }
 
   injectServerConfigurations(context, props);
-  // await injectUserUISettings(context, props);
   await injectNextI18NextConfigurations(context, props);
+  await addActivity(context, getAction(props));
 
   return {
     props,

+ 2 - 1
packages/app/src/pages/tags.page.tsx

@@ -16,7 +16,7 @@ import { useSWRxTagsList } from '~/stores/tag';
 
 import { BasicLayout } from '../components/Layout/BasicLayout';
 import {
-  useCurrentUser,
+  useCurrentUser, useIsSearchPage,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useIsSearchScopeChildrenAsDefault,
 } from '../stores/context';
@@ -55,6 +55,7 @@ const TagPage: NextPage<CommonProps> = (props: Props) => {
   const isLoading = tagDataList === undefined && error == null;
   const classNames: string[] = [];
 
+  useIsSearchPage(false);
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);

+ 2 - 1
packages/app/src/pages/trash.page.tsx

@@ -15,7 +15,7 @@ import GrowiContextualSubNavigation from '../components/Navbar/GrowiContextualSu
 import {
   useCurrentUser, useCurrentPageId, useCurrentPagePath, useCurrentPathname,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
-  useIsSearchScopeChildrenAsDefault,
+  useIsSearchScopeChildrenAsDefault, useIsSearchPage,
 } from '../stores/context';
 
 import {
@@ -41,6 +41,7 @@ const TrashPage: NextPage<CommonProps> = (props: Props) => {
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
 
+  useIsSearchPage(false);
   useCurrentPageId(null);
   useCurrentPathname('/trash');
   useCurrentPagePath('/trash');

+ 1 - 1
packages/app/src/pages/utils/commons.ts

@@ -4,10 +4,10 @@ import { SSRConfig, UserConfig } from 'next-i18next';
 
 import * as nextI18NextConfig from '^/config/next-i18next.config';
 
+import { SupportedActionType } from '~/interfaces/activity';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { GrowiThemes } from '~/interfaces/theme';
 
-
 export type CommonProps = {
   namespacesRequired: string[], // i18next
   currentPathname: string,

+ 2 - 2
packages/app/src/server/middlewares/apiv3-form-validator.ts

@@ -1,11 +1,11 @@
+import { ErrorV3 } from '@growi/core';
 import { NextFunction, Request, Response } from 'express';
+
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:middlewares:ApiV3FormValidator');
 const { validationResult } = require('express-validator');
 
-const ErrorV3 = require('../models/vo/error-apiv3');
-
 export const apiV3FormValidator = (req: Request, res: Response & { apiv3Err }, next: NextFunction): void => {
   logger.debug('req.query', req.query);
   logger.debug('req.params', req.params);

+ 45 - 0
packages/app/src/server/middlewares/invited-form-validator.ts

@@ -0,0 +1,45 @@
+import { NextFunction, Response } from 'express';
+import { body, validationResult, ValidationChain } from 'express-validator';
+import { Request } from 'express-validator/src/base';
+
+const MININUM_PASSWORD_LENGTH = 6;
+
+export const invitedRules = (): ValidationChain[] => {
+  return [
+    body('invitedForm.username')
+      .matches(/^[\da-zA-Z\-_.]+$/)
+      .withMessage('message.Username has invalid characters')
+      .not()
+      .isEmpty()
+      .withMessage('message.Username field is required'),
+    body('invitedForm.name')
+      .not()
+      .isEmpty()
+      .withMessage('message.Name field is required'),
+    body('invitedForm.password')
+      .matches(/^[\x20-\x7F]*$/)
+      .withMessage('message.Password has invalid character')
+      .isLength({ min: MININUM_PASSWORD_LENGTH })
+      .withMessage(`message.Password minimum character should be more than ${MININUM_PASSWORD_LENGTH} characters`)
+      .not()
+      .isEmpty()
+      .withMessage('message.Password field is required'),
+  ];
+};
+
+export const invitedValidation = (req: Request, _res: Response, next: () => NextFunction): any => {
+  const form = req.body;
+  const errors = validationResult(req);
+  const extractedErrors: string[] = [];
+
+  if (errors.isEmpty()) {
+    Object.assign(form, { isValid: true });
+  }
+  else {
+    errors.array().map(err => extractedErrors.push(err.msg));
+    Object.assign(form, { isValid: false, errors: extractedErrors });
+  }
+
+  req.form = form;
+  return next();
+};

+ 0 - 43
packages/app/src/server/middlewares/login-form-validator.ts

@@ -1,48 +1,5 @@
 import { body, validationResult } from 'express-validator';
 
-// form rules
-export const inviteRules = () => {
-  return [
-    body('invitedForm.username')
-      .matches(/^[\da-zA-Z\-_.]+$/)
-      .withMessage('Username has invalid characters')
-      .not()
-      .isEmpty()
-      .withMessage('Username field is required'),
-    body('invitedForm.name').not().isEmpty().withMessage('Name field is required'),
-    body('invitedForm.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'),
-  ];
-};
-
-// validation action
-export const inviteValidation = (req, res, next) => {
-  const form = req.body;
-
-  const errors = validationResult(req);
-  if (errors.isEmpty()) {
-    Object.assign(form, { isValid: true });
-    req.form = form;
-    return next();
-  }
-
-  const extractedErrors: string[] = [];
-  errors.array().map(err => extractedErrors.push(err.msg));
-
-  req.flash('errorMessages', extractedErrors);
-
-  Object.assign(form, { isValid: false });
-  req.form = form;
-
-  return next();
-};
-
 // form rules
 export const loginRules = () => {
   return [

+ 7 - 12
packages/app/src/server/models/vo/error-apiv3.js → packages/app/src/server/models/vo/error-v3.js

@@ -1,3 +1,6 @@
+// The jsdoc below is left here intentionally to be referenced by swagger's $ref in a file under src/server/routes/apiv3/**/*.js such as healthcheck.js
+// The actual ErrorV3 Class is moved to packages/core/models/vo/error-v3.js
+
 /**
  * @swagger
  *
@@ -15,17 +18,9 @@
  *            example: 'someapi-error-with-something'
  *          stack:
  *            type: object
+ *          args:
+ *            type: object
+ *            example: { name: 'Josh', age: 20 }
  */
 
-class ErrorV3 extends Error {
-
-  constructor(message = '', code = '', stack = undefined) {
-    super(); // do not provide message to the super constructor
-    this.message = message;
-    this.code = code;
-    this.stack = stack;
-  }
-
-}
-
-module.exports = ErrorV3;
+export {};

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

@@ -1,3 +1,4 @@
+import { ErrorV3 } from '@growi/core';
 import { body } from 'express-validator';
 
 import { i18n } from '^/config/next-i18next.config';
@@ -11,14 +12,12 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:app-settings');
 
+const { pathUtils } = require('@growi/core');
 const debug = require('debug')('growi:routes:admin');
 const express = require('express');
-const { pathUtils } = require('@growi/core');
 
 const router = express.Router();
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 /**
  * @swagger
  *  tags:

+ 2 - 2
packages/app/src/server/routes/apiv3/attachment.js

@@ -1,16 +1,16 @@
+import { ErrorV3 } from '@growi/core';
+
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:attachment'); // eslint-disable-line no-unused-vars
-
 const express = require('express');
 
 const router = express.Router();
 const { query } = require('express-validator');
 
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
-const ErrorV3 = require('../../models/vo/error-apiv3');
 
 /**
  * @swagger

+ 2 - 2
packages/app/src/server/routes/apiv3/customize-setting.js

@@ -1,5 +1,7 @@
 /* eslint-disable no-unused-vars */
 
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import { AttachmentType } from '~/server/interfaces/attachment';
 import loggerFactory from '~/utils/logger';
@@ -17,8 +19,6 @@ const router = express.Router();
 const { body, query } = require('express-validator');
 const multer = require('multer');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 
 /**
  * @swagger

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

@@ -1,10 +1,10 @@
+import { ErrorV3 } from '@growi/core';
 import { format, subSeconds } from 'date-fns';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import injectResetOrderByTokenMiddleware from '~/server/middlewares/inject-reset-order-by-token-middleware';
 import PasswordResetOrder from '~/server/models/password-reset-order';
-import ErrorV3 from '~/server/models/vo/error-apiv3';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';

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

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:routes:apiv3:healthcheck'); // eslint-disable-line no-unused-vars
@@ -7,7 +9,6 @@ const express = require('express');
 const router = express.Router();
 
 const noCache = require('nocache');
-const ErrorV3 = require('../../models/vo/error-apiv3');
 
 /**
  * @swagger

+ 2 - 3
packages/app/src/server/routes/apiv3/import.js

@@ -1,4 +1,5 @@
-import { SupportedAction } from '~/interfaces/activity';
+import { ErrorV3 } from '@growi/core';
+
 import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
@@ -18,8 +19,6 @@ const multer = require('multer');
 
 const GrowiArchiveImportOption = require('~/models/admin/growi-archive-import-option');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 
 const router = express.Router();
 

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

@@ -52,11 +52,13 @@ module.exports = (crowi, app, isInstalled) => {
   routerForAuth.post('/login', applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation,
     addActivity, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.cannotLoginErrorHadnler, loginPassport.loginFailure);
 
+  routerForAuth.use('/invited', require('./invited')(crowi));
   routerForAuth.use('/logout', require('./logout')(crowi));
 
   routerForAuth.post('/register',
     applicationInstalled, registerFormValidator.registerRules(), registerFormValidator.registerValidation, addActivity, login.register);
 
+
   // installer
   if (!isInstalled) {
     routerForAdmin.use('/installer', require('./installer')(crowi));

+ 1 - 1
packages/app/src/server/routes/apiv3/installer.ts

@@ -1,7 +1,7 @@
+import { ErrorV3 } from '@growi/core';
 import express, { Request, Router } from 'express';
 
 import { SupportedAction } from '~/interfaces/activity';
-import ErrorV3 from '~/server/models/vo/error-apiv3';
 import loggerFactory from '~/utils/logger';
 
 import Crowi from '../../crowi';

+ 53 - 0
packages/app/src/server/routes/apiv3/invited.ts

@@ -0,0 +1,53 @@
+import express, { Request, Router } from 'express';
+
+import Crowi from '../../crowi';
+import { invitedRules, invitedValidation } from '../../middlewares/invited-form-validator';
+
+import { ApiV3Response } from './interfaces/apiv3-response';
+
+type InvitedFormRequest = Request & { form: any, user: any };
+
+module.exports = (crowi: Crowi): Router => {
+  const applicationInstalled = require('../../middlewares/application-installed')(crowi);
+  const debug = require('debug')('growi:routes:login');
+  const User = crowi.model('User');
+  const router = express.Router();
+
+  router.post('/', applicationInstalled, invitedRules(), invitedValidation, async(req: InvitedFormRequest, res: ApiV3Response) => {
+    if (!req.user) {
+      return res.apiv3({ redirectTo: '/login' });
+    }
+
+    if (!req.form.isValid) {
+      return res.apiv3Err(req.form.errors, 400);
+    }
+
+    const user = req.user;
+    const invitedForm = req.form.invitedForm || {};
+    const username = invitedForm.username;
+    const name = invitedForm.name;
+    const password = invitedForm.password;
+
+    // check user upper limit
+    const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
+    if (isUserCountExceedsUpperLimit) {
+      return res.apiv3Err('message.can_not_activate_maximum_number_of_users', 403);
+    }
+
+    const creatable = await User.isRegisterableUsername(username);
+    if (!creatable) {
+      debug('username', username);
+      return res.apiv3Err('message.unable_to_use_this_user', 403);
+    }
+
+    try {
+      await user.activateInvitedUser(username, name, password);
+      return res.apiv3({ redirectTo: '/' });
+    }
+    catch (err) {
+      return res.apiv3Err('message.failed_to_activate', 403);
+    }
+  });
+
+  return router;
+};

+ 2 - 2
packages/app/src/server/routes/apiv3/markdown-setting.js

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
@@ -13,8 +15,6 @@ const router = express.Router();
 
 const { body } = require('express-validator');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 const validator = {
   lineBreak: [
     body('isEnabledLinebreaks').isBoolean(),

+ 2 - 2
packages/app/src/server/routes/apiv3/notification-setting.js

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
@@ -16,8 +18,6 @@ const router = express.Router();
 
 const { body } = require('express-validator');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 const validator = {
   userNotification: [
     body('pathPattern').isString().trim(),

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

@@ -1,3 +1,4 @@
+import { ErrorV3 } from '@growi/core';
 import express, { Request, Router } from 'express';
 import { query, oneOf } from 'express-validator';
 import mongoose from 'mongoose';
@@ -9,7 +10,6 @@ import loggerFactory from '~/utils/logger';
 import Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { PageModel } from '../../models/page';
-import ErrorV3 from '../../models/vo/error-apiv3';
 import PageService from '../../service/page';
 
 import { ApiV3Response } from './interfaces/apiv3-response';

+ 3 - 2
packages/app/src/server/routes/apiv3/page.js

@@ -1,4 +1,6 @@
-import { pagePathUtils, AllSubscriptionStatusType, SubscriptionStatusType } from '@growi/core';
+import {
+  pagePathUtils, AllSubscriptionStatusType, SubscriptionStatusType, ErrorV3,
+} from '@growi/core';
 
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
@@ -15,7 +17,6 @@ const { body, query, param } = require('express-validator');
 
 const router = express.Router();
 const { convertToNewAffiliationPath, isTopPage } = pagePathUtils;
-const ErrorV3 = require('../../models/vo/error-apiv3');
 
 /**
  * @swagger

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

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 import loggerFactory from '~/utils/logger';
@@ -13,8 +15,6 @@ const { body } = require('express-validator');
 const { query } = require('express-validator');
 const mongoose = require('mongoose');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 const { isCreatablePage } = pagePathUtils;
 
 const router = express.Router();

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

@@ -1,6 +1,7 @@
+import { ErrorV3 } from '@growi/core';
+
 import { toArrayIfNot } from '~/utils/array-utils';
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
 
 const addCustomFunctionToResponse = (express, crowi) => {
 

+ 3 - 2
packages/app/src/server/routes/apiv3/revisions.js

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
@@ -5,10 +7,9 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 const logger = loggerFactory('growi:routes:apiv3:pages');
 
 const express = require('express');
-
 const { query, param } = require('express-validator');
+
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
-const ErrorV3 = require('../../models/vo/error-apiv3');
 
 const router = express.Router();
 

+ 2 - 2
packages/app/src/server/routes/apiv3/search.js

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
@@ -14,8 +16,6 @@ const router = express.Router();
 
 const noCache = require('nocache');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 /**
  * @swagger
  *  tags:

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

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import { PageDeleteConfigValue } from '~/interfaces/page-delete-config';
 import loggerFactory from '~/utils/logger';
@@ -16,8 +18,6 @@ const router = express.Router();
 
 const { body } = require('express-validator');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 const validator = {
   generalSetting: [
     body('sessionMaxAge').optional({ checkFalsy: true }).trim().isInt(),

+ 2 - 2
packages/app/src/server/routes/apiv3/share-links.js

@@ -1,5 +1,7 @@
 // TODO remove this setting after implemented all
 /* eslint-disable no-unused-vars */
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import loggerFactory from '~/utils/logger';
@@ -15,8 +17,6 @@ const router = express.Router();
 
 const { body, query, param } = require('express-validator');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 const validator = {};
 
 const today = new Date();

+ 2 - 2
packages/app/src/server/routes/apiv3/slack-integration-legacy-settings.js

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
@@ -14,8 +16,6 @@ const router = express.Router();
 
 const { body } = require('express-validator');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 const validator = {
   slackConfiguration: [
     body('webhookUrl').if(value => value != null).isString().trim(),

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

@@ -1,3 +1,4 @@
+import { ErrorV3 } from '@growi/core';
 
 import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
@@ -20,8 +21,6 @@ const { body, query, param } = require('express-validator');
 const mongoose = require('mongoose');
 const urljoin = require('url-join');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 const logger = loggerFactory('growi:routes:apiv3:slack-integration-settings');
 
 const router = express.Router();

+ 1 - 2
packages/app/src/server/routes/apiv3/slack-integration.js

@@ -1,10 +1,9 @@
+import { ErrorV3 } from '@growi/core';
 import createError from 'http-errors';
 
 import { SlackCommandHandlerError } from '~/server/models/vo/slack-command-handler-error';
 import loggerFactory from '~/utils/logger';
 
-import ErrorV3 from '../../models/vo/error-apiv3';
-
 import {
   markdownSectionBlock, InvalidGrowiCommandError, generateRespondUtil, supportedGrowiCommands,
 } from '@growi/slack';

+ 2 - 1
packages/app/src/server/routes/apiv3/user-activation.ts

@@ -1,7 +1,8 @@
 import path from 'path';
+
+import { ErrorV3 } from '@growi/core';
 import * as express from 'express';
 import { body, validationResult } from 'express-validator';
-import ErrorV3 from '../../models/vo/error-apiv3';
 
 const PASSOWRD_MINIMUM_NUMBER = 8;
 // validation rules for complete registration form

+ 2 - 1
packages/app/src/server/routes/apiv3/user-group-relation.js

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:routes:apiv3:user-group-relation'); // eslint-disable-line no-unused-vars
@@ -5,7 +7,6 @@ const logger = loggerFactory('growi:routes:apiv3:user-group-relation'); // eslin
 const express = require('express');
 const { query } = require('express-validator');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
 const { serializeUserGroupRelationSecurely } = require('../../models/serializers/user-group-relation-serializer');
 
 const router = express.Router();

+ 2 - 1
packages/app/src/server/routes/apiv3/user-group.js

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import UserGroup from '~/server/models/user-group';
 import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
@@ -18,7 +20,6 @@ const { sanitizeQuery } = require('express-validator');
 const mongoose = require('mongoose');
 
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
-const ErrorV3 = require('../../models/vo/error-apiv3');
 const { toPagingLimit, toPagingOffset } = require('../../util/express-validator/sanitizer');
 
 const { ObjectId } = mongoose.Types;

+ 2 - 3
packages/app/src/server/routes/apiv3/user-ui-settings.ts

@@ -1,13 +1,12 @@
+import { ErrorV3 } from '@growi/core';
 import express from 'express';
 import { body } from 'express-validator';
-import { AllSidebarContentsType } from '~/interfaces/ui';
 
+import { AllSidebarContentsType } from '~/interfaces/ui';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-
 import UserUISettings from '../../models/user-ui-settings';
-import ErrorV3 from '../../models/vo/error-apiv3';
 
 const logger = loggerFactory('growi:routes:apiv3:user-ui-settings');
 

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

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import Activity from '~/server/models/activity';
 import loggerFactory from '~/utils/logger';
@@ -19,7 +21,6 @@ const { isEmail } = require('validator');
 
 const { serializePageSecurely } = require('../../models/serializers/page-serializer');
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
-const ErrorV3 = require('../../models/vo/error-apiv3');
 
 const PAGE_ITEMS = 50;
 

+ 2 - 2
packages/app/src/server/routes/index.js

@@ -81,7 +81,7 @@ module.exports = function(crowi, app) {
   app.get('/login/error/:reason'      , applicationInstalled, login.error);
   app.get('/login'                    , applicationInstalled, login.preLogin, next.delegateToNext);
   app.get('/invited'                  , applicationInstalled, next.delegateToNext);
-  app.post('/invited/activateInvited' , applicationInstalled, loginFormValidator.inviteRules(), loginFormValidator.inviteValidation, csrfProtection, login.invited);
+  // app.post('/login'                   , applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation, csrfProtection,  addActivity, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
 
   app.get('/register'                 , applicationInstalled, login.preLogin, login.register);
 
@@ -207,7 +207,7 @@ module.exports = function(crowi, app) {
   app.get('/me/*'                                 , loginRequiredStrictly, injectUserUISettings, next.delegateToNext);
   // external-accounts
   // my in-app-notifications
-  // app.get('/me/all-in-app-notifications'   , loginRequiredStrictly, allInAppNotifications.list);
+  // app.get('/me/all-in-app-notifications'   , loginRequiredStrictly, injectUserUISettings, allInAppNotifications.list);
   // app.get('/me/external-accounts'               , loginRequiredStrictly, injectUserUISettings, me.externalAccounts.list);
   // // my drafts
   // app.get('/me/drafts'                          , loginRequiredStrictly, injectUserUISettings, me.drafts.list);

+ 23 - 11
packages/app/src/server/routes/login-passport.js

@@ -1,8 +1,11 @@
+
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
+import { LoginErrorCode } from '~/interfaces/errors/login-error';
 import { NullUsernameToBeRegisteredError } from '~/server/models/errors';
 import loggerFactory from '~/utils/logger';
 
-import ErrorV3 from '../models/vo/error-apiv3';
 
 /* eslint-disable no-use-before-define */
 
@@ -11,6 +14,7 @@ module.exports = function(crowi, app) {
   const logger = loggerFactory('growi:routes:login-passport');
   const passport = require('passport');
   const ExternalAccount = crowi.model('ExternalAccount');
+  const User = crowi.model('User');
   const passportService = crowi.passportService;
 
   const activityEvent = crowi.event('activity');
@@ -66,7 +70,7 @@ module.exports = function(crowi, app) {
       /* eslint-disable no-else-return */
       if (err instanceof NullUsernameToBeRegisteredError) {
         logger.error(err.message);
-        throw Error(err.message);
+        throw new ErrorV3(err.message);
       }
       else if (err.name === 'DuplicatedUsernameException') {
         if (isSameEmailTreatedAsIdenticalUser || isSameUsernameTreatedAsIdenticalUser) {
@@ -75,11 +79,13 @@ module.exports = function(crowi, app) {
           return ExternalAccount.associate(providerId, userInfo.id, err.user);
         }
         logger.error('provider-DuplicatedUsernameException', providerId);
-        throw Error(`provider-DuplicatedUsernameException: ${providerId}`);
+
+        throw new ErrorV3('message.provider_duplicated_username_exception', LoginErrorCode.PROVIDER_DUPLICATED_USERNAME_EXCEPTION,
+          undefined, { failedProviderForDuplicatedUsernameException: providerId });
       }
       else if (err.name === 'UserUpperLimitException') {
         logger.error(err.message);
-        throw Error(err.message);
+        throw new ErrorV3(err.message);
       }
       /* eslint-enable no-else-return */
     }
@@ -91,6 +97,7 @@ module.exports = function(crowi, app) {
    * @param {*} res
    */
   const loginSuccessHandler = async(req, res, user, action) => {
+
     // update lastLoginAt
     user.updateLastLoginAt(new Date(), (err, userData) => {
       if (err) {
@@ -99,7 +106,9 @@ module.exports = function(crowi, app) {
       }
     });
 
-    const { redirectTo } = req.session;
+    // check for redirection to '/invited'
+    const redirectTo = req.user.status === User.STATUS_INVITED ? '/invited' : req.session.redirectTo;
+
     // remove session.redirectTo
     delete req.session.redirectTo;
 
@@ -112,6 +121,7 @@ module.exports = function(crowi, app) {
         username: req.user.username,
       },
     };
+
     await crowi.activityService.createActivity(parameters);
 
     return res.apiv3({ redirectTo });
@@ -133,21 +143,23 @@ module.exports = function(crowi, app) {
 
   const cannotLoginErrorHadnler = (req, res, next) => {
     // this is called when all login method is somehow failed without invoking 'return next(<any Error>)'
-    const err = res.locals.err != null ? res.locals.err : Error('message.sign_in_failure');
+    const err = new ErrorV3('message.sign_in_failure');
     return next(err);
   };
 
   /**
    * middleware for login failure
+   * @param {*} error
    * @param {*} req
    * @param {*} res
+   * @param {*} next
    */
   const loginFailure = (error, req, res, next) => {
 
     const parameters = { action: SupportedAction.ACTION_USER_LOGIN_FAILURE };
     activityEvent.emit('update', res.locals.activity._id, parameters);
 
-    return res.apiv3Err(error, error.code);
+    return res.apiv3Err(error);
   };
 
   /**
@@ -175,7 +187,7 @@ module.exports = function(crowi, app) {
   const loginWithLdap = async(req, res, next) => {
     if (!passportService.isLdapStrategySetup) {
       debug('LdapStrategy has not been set up');
-      return res.apiv3Err('message.strategy_has_not_been_set_up.LdapStrategy', 405);
+      return next();
     }
 
     if (!req.form.isValid) {
@@ -196,7 +208,7 @@ module.exports = function(crowi, app) {
 
     // check groups for LDAP
     if (!isValidLdapUserByGroupFilter(ldapAccountInfo)) {
-      return next(ErrorV3('message.ldap_user_not_valid', 400));
+      return next(new ErrorV3('message.ldap_user_not_valid'));
     }
 
     /*
@@ -229,7 +241,7 @@ module.exports = function(crowi, app) {
 
     // just in case the returned value is null or undefined
     if (externalAccount == null) {
-      return next(Error('message.external_account_not_exist'));
+      return next(new ErrorV3('message.external_account_not_exist'));
     }
 
     const user = await externalAccount.getPopulatedUser();
@@ -310,7 +322,7 @@ module.exports = function(crowi, app) {
   const loginWithLocal = (req, res, next) => {
     if (!passportService.isLocalStrategySetup) {
       debug('LocalStrategy has not been set up');
-      return res.apiv3Err('message.strategy_has_not_been_set_up.LocalStrategy', 405);
+      return res.apiv3Err(new ErrorV3('message.strategy_has_not_been_set_up', '', undefined, { strategy: 'LocalStrategy' }), 405);
     }
 
     if (!req.form.isValid) {

+ 0 - 45
packages/app/src/server/routes/login.js

@@ -169,50 +169,5 @@ module.exports = function(crowi, app) {
     });
   };
 
-  actions.invited = async function(req, res) {
-    if (!req.user) {
-      return res.redirect('/login');
-    }
-
-    if (req.method === 'POST' && req.form.isValid) {
-      const user = req.user;
-      const invitedForm = req.form.invitedForm || {};
-      const username = invitedForm.username;
-      const name = invitedForm.name;
-      const password = invitedForm.password;
-
-      // check user upper limit
-      const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
-      if (isUserCountExceedsUpperLimit) {
-        req.flash('warningMessage', req.t('message.can_not_activate_maximum_number_of_users'));
-        return res.redirect('/invited');
-      }
-
-      const creatable = await User.isRegisterableUsername(username);
-      if (creatable) {
-        try {
-          await user.activateInvitedUser(username, name, password);
-          return res.redirect('/');
-        }
-        catch (err) {
-          req.flash('warningMessage', req.t('message.failed_to_activate'));
-          return res.render('invited');
-        }
-      }
-      else {
-        req.flash('warningMessage', req.t('message.unable_to_use_this_user'));
-        debug('username', username);
-        return res.render('invited');
-      }
-    }
-    else {
-      return res.render('invited');
-    }
-  };
-
-  actions.updateInvitedUser = function(req, res) {
-    return res.redirect('/');
-  };
-
   return actions;
 };

+ 15 - 1
packages/app/src/server/service/installer.ts

@@ -1,6 +1,7 @@
 import path from 'path';
 
 import { Lang } from '@growi/core';
+import { addSeconds } from 'date-fns';
 import ExtensibleCustomError from 'extensible-custom-error';
 import fs from 'graceful-fs';
 import mongoose from 'mongoose';
@@ -78,7 +79,20 @@ export class InstallerService {
         // TODO typescriptize models/user.js and remove eslint-disable-next-line
         // eslint-disable-next-line @typescript-eslint/no-explicit-any
         const Page = mongoose.model('Page') as any;
-        await Page.updateMany({}, { createdAt: initialPagesCreatedAt, updatedAt: initialPagesCreatedAt });
+
+        // Increment timestamp to avoid difference for order in VRT
+        const pagePaths = ['/Sandbox', '/Sandbox/Bootstrap4', '/Sandbox/Diagrams', '/Sandbox/Math'];
+        const promises = pagePaths.map(async(path: string, idx: number) => {
+          const date = addSeconds(initialPagesCreatedAt, idx);
+          return Page.update(
+            { path },
+            {
+              createdAt: date,
+              updatedAt: date,
+            },
+          );
+        });
+        await Promise.all(promises);
       }
       catch (err) {
         logger.error('Failed to update createdAt', err);

+ 9 - 5
packages/app/src/stores/context.tsx

@@ -1,3 +1,4 @@
+import { IUser } from '@growi/core';
 import { HtmlElementNode } from 'rehype-toc';
 import { Key, SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
@@ -11,7 +12,6 @@ import { GrowiThemes } from '~/interfaces/theme';
 import InterceptorManager from '~/services/interceptor-manager';
 
 import { TargetAndAncestors } from '../interfaces/page-listing-results';
-import { IUser, IUserHasId } from '../interfaces/user';
 
 import { useStaticSWR } from './use-static-swr';
 
@@ -98,10 +98,6 @@ export const useIsNotFound = (initialData?: boolean): SWRResponse<boolean, Error
   return useStaticSWR<boolean, Error>('isNotFound', initialData, { fallbackData: false });
 };
 
-export const usePageUser = (initialData?: IUserHasId): SWRResponse<IUserHasId, Error> => {
-  return useStaticSWR<IUserHasId, Error>('pageUser', initialData);
-};
-
 export const useHasChildren = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
   return useStaticSWR<Nullable<any>, Error>('hasChildren', initialData);
 };
@@ -134,6 +130,10 @@ export const useRevisionIdHackmdSynced = (initialData?: Nullable<any>): SWRRespo
   return useStaticSWR<Nullable<any>, Error>('revisionIdHackmdSynced', initialData);
 };
 
+export const useDrawioUri = (initialData?: string): SWRResponse<string, Error> => {
+  return useStaticSWR('drawioUri', initialData, { fallbackData: 'https://embed.diagrams.net/' });
+};
+
 export const useHackmdUri = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
   return useStaticSWR<Nullable<string>, Error>('hackmdUri', initialData);
 };
@@ -258,6 +258,10 @@ export const useShowPageLimitationXL = (initialData?: number): SWRResponse<numbe
   return useStaticSWR('showPageLimitationXL', initialData);
 };
 
+export const useCustomizeTitle = (initialData?: string): SWRResponse<string, Error> => {
+  return useStaticSWR('CustomizeTitle', initialData);
+};
+
 /** **********************************************************
  *                     Computed contexts
  *********************************************************** */

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