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

Merge branch 'support/apply-nextjs-2' into feat/g2g-nextjs

atsuki-t 3 лет назад
Родитель
Сommit
dbb3c9b4ac
100 измененных файлов с 2331 добавлено и 1637 удалено
  1. 1 1
      lerna.json
  2. 1 1
      package.json
  3. 1 2
      packages/app/bin/github-actions/update-readme.sh
  4. 3 4
      packages/app/docker/README.md
  5. 20 20
      packages/app/package.json
  6. 3 0
      packages/app/public/static/locales/en_US/admin.json
  7. 30 4
      packages/app/public/static/locales/en_US/translation.json
  8. 3 0
      packages/app/public/static/locales/ja_JP/admin.json
  9. 30 4
      packages/app/public/static/locales/ja_JP/translation.json
  10. 3 0
      packages/app/public/static/locales/zh_CN/admin.json
  11. 30 4
      packages/app/public/static/locales/zh_CN/translation.json
  12. 4 3
      packages/app/src/client/util/smooth-scroll.ts
  13. 2 2
      packages/app/src/components/Admin/App/AppSetting.jsx
  14. 3 4
      packages/app/src/components/BookmarkButtons.module.scss
  15. 2 2
      packages/app/src/components/BookmarkButtons.tsx
  16. 1 1
      packages/app/src/components/ContentLinkButtons.module.scss
  17. 47 44
      packages/app/src/components/ContentLinkButtons.tsx
  18. 39 33
      packages/app/src/components/Fab.tsx
  19. 1 5
      packages/app/src/components/Icons/CreatePageIcon.tsx
  20. 1 6
      packages/app/src/components/Icons/ReturnTopIcon.tsx
  21. 0 217
      packages/app/src/components/InstallerForm.jsx
  22. 231 0
      packages/app/src/components/InstallerForm.tsx
  23. 4 4
      packages/app/src/components/Layout/BasicLayout.tsx
  24. 50 0
      packages/app/src/components/Layout/ShareLinkLayout.tsx
  25. 4 4
      packages/app/src/components/LikeButtons.module.scss
  26. 2 2
      packages/app/src/components/LikeButtons.tsx
  27. 0 365
      packages/app/src/components/LoginForm.jsx
  28. 441 0
      packages/app/src/components/LoginForm.tsx
  29. 0 1
      packages/app/src/components/Me/BasicInfoSettings.tsx
  30. 69 52
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  31. 4 5
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  32. 15 1
      packages/app/src/components/Navbar/GrowiSubNavigation.module.scss
  33. 20 45
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  34. 11 6
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  35. 3 2
      packages/app/src/components/Page.tsx
  36. 7 11
      packages/app/src/components/Page/DisplaySwitcher.tsx
  37. 0 52
      packages/app/src/components/Page/ShareLinkAlert.jsx
  38. 52 0
      packages/app/src/components/Page/ShareLinkAlert.tsx
  39. 62 65
      packages/app/src/components/PageAttachment.tsx
  40. 0 97
      packages/app/src/components/PageAttachment/DeleteAttachmentModal.jsx
  41. 98 0
      packages/app/src/components/PageAttachment/DeleteAttachmentModal.tsx
  42. 0 45
      packages/app/src/components/PageAttachment/PageAttachmentList.jsx
  43. 47 0
      packages/app/src/components/PageAttachment/PageAttachmentList.tsx
  44. 2 2
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  45. 1 2
      packages/app/src/components/PagePathNav.tsx
  46. 8 7
      packages/app/src/components/PasswordResetExecutionForm.tsx
  47. 13 12
      packages/app/src/components/PasswordResetRequestForm.tsx
  48. 2 5
      packages/app/src/components/SavePageControls.tsx
  49. 4 3
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  50. 5 9
      packages/app/src/components/ShareLink/ShareLinkForm.tsx
  51. 2 2
      packages/app/src/components/Skelton.tsx
  52. 22 0
      packages/app/src/components/User/SeenUserInfo.module.scss
  53. 5 1
      packages/app/src/components/User/SeenUserInfo.tsx
  54. 4 9
      packages/app/src/components/User/UserInfo.tsx
  55. 2 4
      packages/app/src/interfaces/attachment.ts
  56. 7 0
      packages/app/src/interfaces/errors/forgot-password.ts
  57. 11 0
      packages/app/src/interfaces/share-link.ts
  58. 8 3
      packages/app/src/pages/[[...path]].page.tsx
  59. 2 1
      packages/app/src/pages/admin/[[...path]].page.tsx
  60. 90 0
      packages/app/src/pages/forgot-password-errors.page.tsx
  61. 60 0
      packages/app/src/pages/forgot-password.page.tsx
  62. 26 6
      packages/app/src/pages/login.page.tsx
  63. 3 2
      packages/app/src/pages/me/[[...path]].page.tsx
  64. 72 0
      packages/app/src/pages/reset-password.page.tsx
  65. 169 0
      packages/app/src/pages/share/[[...path]].page.tsx
  66. 5 2
      packages/app/src/pages/tags.page.tsx
  67. 7 5
      packages/app/src/pages/utils/commons.ts
  68. 15 2
      packages/app/src/server/middlewares/inject-reset-order-by-token-middleware.ts
  69. 9 7
      packages/app/src/server/middlewares/login-form-validator.ts
  70. 8 8
      packages/app/src/server/middlewares/register-form-validator.ts
  71. 20 1
      packages/app/src/server/routes/apiv3/index.js
  72. 76 0
      packages/app/src/server/routes/apiv3/installer.ts
  73. 41 14
      packages/app/src/server/routes/forgot-password.ts
  74. 7 10
      packages/app/src/server/routes/index.js
  75. 0 70
      packages/app/src/server/routes/installer.js
  76. 113 90
      packages/app/src/server/routes/login-passport.js
  77. 78 94
      packages/app/src/server/routes/login.js
  78. 3 1
      packages/app/src/server/routes/next.ts
  79. 2 2
      packages/app/src/server/service/installer.ts
  80. 13 9
      packages/app/src/stores/attachment.tsx
  81. 2 2
      packages/app/src/stores/context.tsx
  82. 12 12
      packages/app/src/stores/ui.tsx
  83. 0 9
      packages/app/src/styles/_on-edit.scss
  84. 0 37
      packages/app/src/styles/atoms/_buttons.scss
  85. 2 0
      packages/app/src/styles/atoms/_custom_control.scss
  86. 0 13
      packages/app/src/styles/atoms/_nav.scss
  87. 0 4
      packages/app/src/styles/atoms/_pre.scss
  88. 2 5
      packages/app/src/styles/style-next.scss
  89. 1 1
      packages/app/src/styles/theme/_apply-colors.scss
  90. 1 1
      packages/codemirror-textlint/package.json
  91. 1 1
      packages/core/package.json
  92. 1 0
      packages/core/src/interfaces/user.ts
  93. 1 1
      packages/plugin-attachment-refs/package.json
  94. 6 11
      packages/plugin-lsx/package.json
  95. 1 1
      packages/remark-growi-plugin/package.json
  96. 1 1
      packages/slack/package.json
  97. 2 2
      packages/slackbot-proxy/package.json
  98. 4 8
      packages/ui/package.json
  99. 3 1
      packages/ui/src/components/Attachment/Attachment.jsx
  100. 31 30
      yarn.lock

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

+ 1 - 2
packages/app/bin/github-actions/update-readme.sh

@@ -2,5 +2,4 @@
 
 cd docker
 
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`5\.1\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/packages\/app\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}\2\3${RELEASED_VERSION}\4/" README.md
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`5\.1-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/packages\/app\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}-nocdn\2\3${RELEASED_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`6\.0\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/packages\/app\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}\2\3${RELEASED_VERSION}\4/" README.md

+ 3 - 4
packages/app/docker/README.md

@@ -10,10 +10,9 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`5.1.4`, `5.1`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.4/packages/app/docker/Dockerfile)
-* [`5.1.4-nocdn`, `5.1-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.4/packages/app/docker/Dockerfile)
-* [`5.0.11`, `5.0` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/packages/app/docker/Dockerfile)
-* [`5.0.11-nocdn`, `5.0-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/packages/app/docker/Dockerfile)
+* [`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)
 * [`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)
 

+ 20 - 20
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.1.5-RC.0",
+  "version": "6.0.0-RC.1",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -64,11 +64,11 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.1.5-RC.0",
-    "@growi/core": "^5.1.5-RC.0",
-    "@growi/plugin-attachment-refs": "^5.1.5-RC.0",
-    "@growi/plugin-lsx": "^5.1.5-RC.0",
-    "@growi/slack": "^5.1.5-RC.0",
+    "@growi/codemirror-textlint": "^6.0.0-RC.1",
+    "@growi/core": "^6.0.0-RC.1",
+    "@growi/plugin-attachment-refs": "^6.0.0-RC.1",
+    "@growi/plugin-lsx": "^6.0.0-RC.1",
+    "@growi/slack": "^6.0.0-RC.1",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -130,7 +130,7 @@
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "next": "^12.2.5",
-    "next-i18next": "^11.0.0",
+    "next-i18next": "^11.3.0",
     "next-superjson": "^0.0.4",
     "next-themes": "^0.2.0",
     "nocache": "^3.0.1",
@@ -150,6 +150,7 @@
     "prom-client": "^13.0.0",
     "rate-limiter-flexible": "^2.3.7",
     "react": "^18.2.0",
+    "react-bootstrap-typeahead": "^5.2.2",
     "react-card-flip": "^1.0.10",
     "react-datepicker": "^4.7.0",
     "react-dnd": "^14.0.5",
@@ -159,6 +160,9 @@
     "react-markdown": "^8.0.3",
     "react-multiline-clamp": "^2.0.0",
     "react-syntax-highlighter": "^15.5.0",
+    "react-use-ripple": "^1.5.2",
+    "react-scroll": "^1.8.7",
+    "reactstrap": "^8.9.0",
     "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
     "rehype-katex": "^6.0.2",
@@ -172,12 +176,17 @@
     "remark-math": "^5.1.1",
     "remark-wiki-link": "^1.0.4",
     "rimraf": "^3.0.0",
+    "simplebar-react": "^2.3.6",
     "socket.io": "^4.2.0",
+    "sticky-events": "^3.4.11",
     "stream-to-promise": "^3.0.0",
     "string-width": "=4.2.2",
     "superjson": "^1.9.1",
     "swagger-jsdoc": "^6.1.0",
     "swig-templates": "^2.0.2",
+    "swr": "^1.3.0",
+    "throttle-debounce": "^3.0.1",
+    "toastr": "^2.1.2",
     "uglifycss": "^0.0.29",
     "universal-bunyan": "^0.9.2",
     "unzipper": "^0.10.5",
@@ -185,7 +194,8 @@
     "usehooks-ts": "^2.6.0",
     "validator": "^13.7.0",
     "ws": "^8.3.0",
-    "xss": "^1.0.6"
+    "xss": "^1.0.6",
+    "unstated": "^2.1.1"
   },
   "// comments for defDependencies": {
     "@handsontable/react": "v3 requires handsontable >= 7.0.0.",
@@ -193,7 +203,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.1.5-RC.0",
+    "@growi/ui": "^6.0.0-RC.1",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^12.2.3",
@@ -231,31 +241,21 @@
     "penpal": "^4.0.0",
     "plantuml-encoder": "^1.2.5",
     "prettier": "^1.19.1",
-    "react-bootstrap-typeahead": "^5.2.2",
     "react-codemirror2": "^6.0.0",
     "react-copy-to-clipboard": "^5.0.1",
     "react-dropzone": "^11.2.4",
     "react-frame-component": "^4.0.0",
     "react-hotkeys": "^2.0.0",
-    "react-scroll": "^1.8.7",
-    "react-use-ripple": "^1.5.2",
     "react-waypoint": "^10.1.0",
-    "reactstrap": "^8.9.0",
     "rehype-rewrite": "^3.0.6",
     "replacestream": "^4.0.3",
     "reveal.js": "^4.3.1",
     "sass": "^1.53.0",
     "simple-line-icons": "^2.5.5",
     "simple-load-script": "^1.0.2",
-    "simplebar-react": "^2.3.6",
     "socket.io-client": "^4.2.0",
-    "sticky-events": "^3.4.11",
     "swagger2openapi": "^5.3.1",
-    "swr": "^1.3.0",
-    "throttle-debounce": "^3.0.1",
-    "toastr": "^2.1.2",
     "ts-node-dev": "^2.0.0",
-    "tsc-alias": "^1.2.9",
-    "unstated": "^2.1.1"
+    "tsc-alias": "^1.2.9"
   }
 }

+ 3 - 0
packages/app/public/static/locales/en_US/admin.json

@@ -1,4 +1,7 @@
 {
+  "meta": {
+    "display_name": "English"
+  },
   "wiki_management_home_page": "Wiki Management Home Page",
   "app_settings": "App Settings",
   "security_settings": {

+ 30 - 4
packages/app/public/static/locales/en_US/translation.json

@@ -191,7 +191,9 @@
     "setup": "Setup",
     "create_initial_account": "Create an initial account",
     "initial_account_will_be_administrator_automatically": "The initial account will be administrator automatically.",
-    "unavaliable_user_id": "This 'User ID' is unavailable."
+    "unavaliable_user_id": "This 'User ID' is unavailable.",
+    "failed_to_install": "Failed to install GROWI. Please try again.",
+    "failed_to_login_after_install": "Failed to login after installation. Redirecting to the login form ..."
   },
   "breaking_changes": {
     "v346_using_basic_auth": "Basic Authentication currently in use will <strong>no longer be available</strong> in the near future. Remove settings from %s"
@@ -689,9 +691,19 @@
     "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": "{{strategy}} has not been set up",
+    "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"
+    },
+    "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.",
-    "database_error": "Database Server Error occured",
     "sign_in_failure": "Sign in failure.",
     "aws_sttings_required": "AWS settings required to use this function. Please ask the administrator.",
     "application_already_installed": "Application already installed.",
@@ -709,7 +721,20 @@
     "complete_to_install2":"Complete to Install GROWI ! Please check each settings on this page first.",
     "failed_to_create_admin_user":"Failed to create admin user. {{errMessage}}",
     "successfully_send_email_auth":"We sent an email to {{email}}. Please click the URL in the email and complete the registration.",
-    "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired."
+    "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired.",
+    "user_already_loggedin": "You cannot create a new account when you are logged in.",
+    "registration_closed": "You are not authorized to create a new account.",
+    "Username has invalid characters": "Username has invalid characters.",
+    "Username field is required": "User ID field is required.",
+    "Name field is required": "Name field is required.",
+    "Email format is invalid": "Email format is invalid.",
+    "Email field is required": "Email field is required.",
+    "Password has invalid character": "Password has invalid character.",
+    "Password minimum character should be more than 8 characters": "Password minimum character should be more than 8 characters.",
+    "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."
   },
   "grid_edit":{
     "create_bootstrap_4_grid":"Create Bootstrap 4 Grid",
@@ -736,6 +761,7 @@
     "confirm_new_password": "Confirm the new password",
     "email_is_required": "Email is required",
     "success_to_send_email": "Success to send email",
+    "feature_is_unavailable": "This feature is unavailable.",
     "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired. Please resend a password reset request via the link below.",
     "password_and_confirm_password_does_not_match": "Password and confirm password does not match"
   },

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

@@ -1,4 +1,7 @@
 {
+  "meta": {
+    "display_name": "日本語"
+  },
   "Update": "更新",
   "Delete": "削除",
   "User": "ユーザー",

+ 30 - 4
packages/app/public/static/locales/ja_JP/translation.json

@@ -184,7 +184,9 @@
     "setup": "セットアップ",
     "create_initial_account": "最初のアカウントの作成",
     "initial_account_will_be_administrator_automatically": "初めに作成するアカウントは、自動的に管理者権限が付与されます",
-    "unavaliable_user_id": "このユーザーIDは利用できません。"
+    "unavaliable_user_id": "このユーザーIDは利用できません。",
+    "failed_to_install": "GROWI のインストールに失敗しました。再度お試しください。",
+    "failed_to_login_after_install": "インストール後、ログインに失敗しました。ログインフォームに遷移しています ..."
   },
   "breaking_changes": {
     "v346_using_basic_auth": "現在利用中の Basic 認証機能は、近い将来<strong>廃止されます</strong>。%s から設定を削除してください。"
@@ -680,9 +682,19 @@
     "fail_to_save_access_token": "アクセストークンの保存に失敗しました、再度お試しください。",
     "fail_to_fetch_access_token": "アクセストークンの取得に失敗しました、再度お試しください。",
     "successfully_disconnected": "切断に成功しました!",
-    "strategy_has_not_been_set_up": "{{strategy}} はセットアップされていません。",
+    "strategy_has_not_been_set_up": {
+      "LocalStrategy"  : "LocalStrategy はセットアップされていません。",
+      "LdapStrategy"   : "LdapStrategy はセットアップされていません。",
+      "GoogleStrategy" : "GoogleStrategy はセットアップされていません。",
+      "GitHubStrategy" : "GitHubStrategy はセットアップされていません。",
+      "TwitterStrategy": "TwitterStrategy はセットアップされていません。",
+      "OidcStrategy"   : "OidcStrategy はセットアップされていません。",
+      "SamlStrategy"   : "SamlStrategy はセットアップされていません。",
+      "Basic"          : "Basic はセットアップされていません。"
+    },
+    "ldap_user_not_valid": "Ldap user is no valid",
+    "external_account_not_exist": "外部アカウントが見つからない、または作成に失敗しました",
     "maximum_number_of_users": "ユーザー数が上限を超えたためアクティベートできません。",
-    "database_error":"データベースサーバーに問題があります。",
     "sign_in_failure": "ログインに失敗しました。",
     "aws_sttings_required": "この機能にはAWS設定が必要です。管理者に訪ねて下さい。",
     "application_already_installed": "アプリケーションのインストールが完了しました。",
@@ -700,7 +712,20 @@
     "complete_to_install2":"GROWI のインストールが完了しました!はじめに、このページで各種設定を確認してください。",
     "failed_to_create_admin_user":"管理ユーザーの作成に失敗しました。{{errMessage}}",
     "successfully_send_email_auth":"{{email}} にメールを送信しました。添付されたURLをクリックし、本登録を完了させてください",
-    "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。"
+    "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。",
+    "user_already_loggedin": "ログイン中のため、新規アカウントを作成できませんでした。",
+    "registration_closed": "新しいアカウントを作成する権限がありません。",
+    "Username has invalid characters": "ユーザー名に不正な文字が含まれています.",
+    "Username field is required": "User ID は必須項目です",
+    "Name field is required": "ユーザーID は必須項目です",
+    "Email format is invalid": "メールアドレスのフォーマットが無効です",
+    "Email field is required": "メールアドレスは必須項目です",
+    "Password has invalid character": "パスワードに無効な文字があります",
+    "Password minimum character should be more than 8 characters": "パスワードの最小文字数は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": "ユーザーが見つかりません"
   },
   "grid_edit":{
     "create_bootstrap_4_grid":"Bootstrap 4 グリッドを作成",
@@ -727,6 +752,7 @@
     "confirm_new_password": "新しいパスワードの確認",
     "email_is_required": "メールを入力してください",
     "success_to_send_email": "メールを送信しました",
+    "feature_is_unavailable": "この機能を利用することはできません。",
     "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。 以下のリンクからパスワードリセットリクエストを再送信してください。",
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
   },

+ 3 - 0
packages/app/public/static/locales/zh_CN/admin.json

@@ -1,4 +1,7 @@
 {
+  "meta": {
+    "display_name": "简体中文"
+  },
   "Update": "更新",
   "Delete": "删除",
   "User": "用户",

+ 30 - 4
packages/app/public/static/locales/zh_CN/translation.json

@@ -186,7 +186,9 @@
 		"setup": "安装",
 		"create_initial_account": "创建初始用户",
 		"initial_account_will_be_administrator_automatically": "初始帐户将自动成为管理员。",
-		"unavaliable_user_id": "用户ID不可用"
+		"unavaliable_user_id": "用户ID不可用",
+    "failed_to_install": "GROWI安装失败。请再试一次。",
+    "failed_to_login_after_install": "安装后登录失败。重定向到登录表格..."
 	},
 	"breaking_changes": {
 		"v346_using_basic_auth": "当前使用的基本身份验证在不久的将来将不再可用。从%s中删除设置"
@@ -736,9 +738,19 @@
 		"fail_to_save_access_token": "无法保存访问令牌。请再试一次。",
 		"fail_to_fetch_access_token": "无法获取访问令牌。请重新连接。",
 		"successfully_disconnected": "成功断开连接!",
-		"strategy_has_not_been_set_up": "{{strategy}尚未设置",
+    "strategy_has_not_been_set_up": {
+      "LocalStrategy"  : "LocalStrategy 尚未设置",
+      "LdapStrategy"   : "LdapStrategy 尚未设置",
+      "GoogleStrategy" : "GoogleStrategy 尚未设置",
+      "GitHubStrategy" : "GitHubStrategy 尚未设置",
+      "TwitterStrategy": "TwitterStrategy 尚未设置",
+      "OidcStrategy"   : "OidcStrategy 尚未设置",
+      "SamlStrategy"   : "SamlStrategy 尚未设置",
+      "Basic"          : "Basic 尚未设置"
+     },
+    "ldap_user_not_valid": "Ldap user is no valid",
+    "external_account_not_exist": "查找或创建外部账户失败",
 		"maximum_number_of_users": "注册的用户数不能超过最大值。",
-		"database_error": "发生数据库服务器错误",
 		"sign_in_failure": "登录失败。",
 		"aws_sttings_required": "使用此功能所需的AWS设置。请询问管理员。",
 		"application_already_installed": "应用程序已安装。",
@@ -756,7 +768,20 @@
 		"complete_to_install2": "完成安装GROWI!请先检查此页上的每个设置。",
 		"failed_to_create_admin_user": "无法创建管理用户。{{errMessage}",
     "successfully_send_email_auth":"我们向 {{email}} 发送了一封电子邮件。 请点击邮件中的网址并完成注册。",
-    "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。"
+    "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。",
+    "user_already_loggedin": "当你登录的时候,你不能创建一个新的账户。",
+    "registration_closed": "你无权创建一个新的账户。",
+    "Username has invalid characters": "用户名有无效字符",
+    "Username field is required": "用户ID字段是必需的",
+    "Name field is required": "姓名字段为必填项",
+    "Email format is invalid": "电子邮件的格式是无效的",
+    "Email field is required": "电子邮件字段是必需的",
+    "Password has invalid character": "密码有无效字符",
+    "Password minimum character should be more than 8 characters": "密码最小字符应超过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": "未找到用户"
 	},
   "grid_edit":{
     "create_bootstrap_4_grid":"创建Bootstrap 4网格",
@@ -783,6 +808,7 @@
     "confirm_new_password": "确认新密码",
     "email_is_required": "电子邮件是必需的",
     "success_to_send_email": "我发了一封电子邮件",
+    "feature_is_unavailable": "此功能不可用",
     "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。 请通过以下链接重新发送密码重置请求",
     "password_and_confirm_password_does_not_match": "密码和确认密码不匹配"
   },

+ 4 - 3
packages/app/src/client/util/smooth-scroll.ts

@@ -1,10 +1,11 @@
 const WIKI_HEADER_LINK = 120;
 
-export const smoothScrollIntoView = (element: HTMLElement, offsetTop = 0, scrollElement: HTMLElement | Window = window): void => {
-  const targetElement = element || window.document.body;
+export const smoothScrollIntoView = (
+    element: HTMLElement = window.document.body, offsetTop = 0, scrollElement: HTMLElement | Window = window,
+): void => {
 
   // get the distance to the target element top
-  const rectTop = targetElement.getBoundingClientRect().top;
+  const rectTop = element.getBoundingClientRect().top;
 
   const top = window.pageYOffset + rectTop - offsetTop;
 

+ 2 - 2
packages/app/src/components/Admin/App/AppSetting.jsx

@@ -79,8 +79,8 @@ const AppSetting = (props) => {
         <div className="col-md-6 py-2">
           {
             i18nConfig.locales.map((locale) => {
-              const fixedT = i18n.getFixedT(locale);
-              i18n.loadLanguages(i18nConfig.locales);
+              if (i18n == null) { return }
+              const fixedT = i18n.getFixedT(locale, 'admin');
 
               return (
                 <div key={locale} className="custom-control custom-radio custom-control-inline">

+ 3 - 4
packages/app/src/components/BookmarkButtons.module.scss

@@ -1,12 +1,11 @@
 @use '~/styles/bootstrap/init' as bs;
 
-.btn-bookmark {
-  :global {
+.btn-group-bookmark :global {
+  .btn-bookmark {
     box-shadow: none !important;
-  }
 
-  &:global {
     @include bs.button-outline-variant(rgba(bs.$secondary, 50%), bs.$orange, rgba(lighten(bs.$orange, 20%), 0.5), rgba(lighten(bs.$orange, 20%), 0.5));
+
     &:not(:disabled):not(.disabled):active,
     &:not(:disabled):not(.disabled).active {
       color: bs.$orange;

+ 2 - 2
packages/app/src/components/BookmarkButtons.tsx

@@ -52,12 +52,12 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
   }, [isGuestUser, isBookmarked]);
 
   return (
-    <div className="btn-group" role="group" aria-label="Bookmark buttons">
+    <div className={`btn-group btn-group-bookmark ${styles['btn-group-bookmark']}`} role="group" aria-label="Bookmark buttons">
       <button
         type="button"
         id="bookmark-button"
         onClick={handleClick}
-        className={`shadow-none btn btn-bookmark ${styles['btn-bookmark']} border-0
+        className={`shadow-none btn btn-bookmark border-0
           ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
         <i className={`fa ${isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>

+ 1 - 1
packages/app/src/styles/_toc.scss → packages/app/src/components/ContentLinkButtons.module.scss

@@ -1,4 +1,4 @@
-.grw-icon-container-recently-created {
+.grw-icon-container-recently-created :global {
   svg {
     width: 14px;
     height: 14px;

+ 47 - 44
packages/app/src/components/ContentLinkButtons.tsx

@@ -1,57 +1,62 @@
-import React, { useCallback, useMemo } from 'react';
+import React, { useCallback } from 'react';
 
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { RecentlyCreatedIcon } from '~/components/Icons/RecentlyCreatedIcon';
 import { usePageUser } from '~/stores/context';
 
+import styles from './ContentLinkButtons.module.scss';
 
 const WIKI_HEADER_LINK = 120;
 
+const BookMarkLinkButton = React.memo(() => {
 
-const ContentLinkButtons = (): JSX.Element => {
+  const BookMarkLinkButtonClickHandler = useCallback(() => {
+    const getBookMarkListHeaderDom = document.getElementById('bookmarks-list');
+    if (getBookMarkListHeaderDom == null) { return }
+    smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK);
+  }, []);
+
+  return (
+    <button
+      type="button"
+      className="btn btn-outline-secondary btn-sm px-2"
+      onClick={BookMarkLinkButtonClickHandler}
+    >
+      <i className="fa fa-fw fa-bookmark-o"></i>
+      <span>Bookmarks</span>
+    </button>
+  );
+});
+
+BookMarkLinkButton.displayName = 'BookMarkLinkButton';
+
+const RecentlyCreatedLinkButton = React.memo(() => {
+
+  const RecentlyCreatedListButtonClickHandler = useCallback(() => {
+    const getRecentlyCreatedListHeaderDom = document.getElementById('recently-created-list');
+    if (getRecentlyCreatedListHeaderDom == null) { return }
+    smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK);
+  }, []);
+
+  return (
+    <button
+      type="button"
+      className="btn btn-outline-secondary btn-sm px-3"
+      onClick={RecentlyCreatedListButtonClickHandler}
+    >
+      <i className={`${styles['grw-icon-container-recently-created']} grw-icon-container-recently-created mr-2`}><RecentlyCreatedIcon /></i>
+      <span>Recently Created</span>
+    </button>
+  );
+});
+
+RecentlyCreatedLinkButton.displayName = 'RecentlyCreatedLinkButton';
+
+export const ContentLinkButtons = (): JSX.Element => {
 
   const { data: pageUser } = usePageUser();
 
-  // get element for smoothScroll
-  const getBookMarkListHeaderDom = useMemo(() => { return document.getElementById('bookmarks-list') }, []);
-  const getRecentlyCreatedListHeaderDom = useMemo(() => { return document.getElementById('recently-created-list') }, []);
-
-
-  const BookMarkLinkButton = useCallback((): JSX.Element => {
-    if (getBookMarkListHeaderDom == null) {
-      return <></>;
-    }
-
-    return (
-      <button
-        type="button"
-        className="btn btn-outline-secondary btn-sm px-2"
-        onClick={() => smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
-      >
-        <i className="fa fa-fw fa-bookmark-o"></i>
-        <span>Bookmarks</span>
-      </button>
-    );
-  }, [getBookMarkListHeaderDom]);
-
-  const RecentlyCreatedLinkButton = useCallback(() => {
-    if (getRecentlyCreatedListHeaderDom == null) {
-      return <></>;
-    }
-
-    return (
-      <button
-        type="button"
-        className="btn btn-outline-secondary btn-sm px-3"
-        onClick={() => smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
-      >
-        <i className="grw-icon-container-recently-created mr-2"><RecentlyCreatedIcon /></i>
-        <span>Recently Created</span>
-      </button>
-    );
-  }, [getRecentlyCreatedListHeaderDom]);
-
-  if (pageUser == null) {
+  if (pageUser == null || pageUser.status === 4) {
     return <></>;
   }
 
@@ -63,5 +68,3 @@ const ContentLinkButtons = (): JSX.Element => {
   );
 
 };
-
-export default ContentLinkButtons;

+ 39 - 33
packages/app/src/components/Fab.jsx → packages/app/src/components/Fab.tsx

@@ -10,18 +10,18 @@ import { useCurrentPagePath, useCurrentUser } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 import loggerFactory from '~/utils/logger';
 
-import CreatePageIcon from './Icons/CreatePageIcon';
-import ReturnTopIcon from './Icons/ReturnTopIcon';
+import { CreatePageIcon } from './Icons/CreatePageIcon';
+import { ReturnTopIcon } from './Icons/ReturnTopIcon';
 
 import styles from './Fab.module.scss';
 
 const logger = loggerFactory('growi:cli:Fab');
 
-const Fab = () => {
-  const { data: currentUser } = useCurrentUser();
+export const Fab = (): JSX.Element => {
 
-  const { open: openCreateModal } = usePageCreateModal();
+  const { data: currentUser } = useCurrentUser();
   const { data: currentPath = '' } = useCurrentPagePath();
+  const { open: openCreateModal } = usePageCreateModal();
 
   const [animateClasses, setAnimateClasses] = useState('invisible');
   const [buttonClasses, setButtonClasses] = useState('');
@@ -30,32 +30,40 @@ const Fab = () => {
   const createBtnRef = useRef(null);
   useRipple(createBtnRef, { rippleColor: 'rgba(255, 255, 255, 0.3)' });
 
-  const stickyChangeHandler = useCallback((event) => {
-    logger.debug('StickyEvents.CHANGE detected');
-
-    const newAnimateClasses = event.detail.isSticky ? 'animated fadeInUp faster' : 'animated fadeOut faster';
-    const newButtonClasses = event.detail.isSticky ? '' : 'disabled grw-pointer-events-none';
-
-    setAnimateClasses(newAnimateClasses);
-    setButtonClasses(newButtonClasses);
-  }, []);
-
-  // setup effect by sticky event
-  useEffect(() => {
-    // sticky
-    // See: https://github.com/ryanwalters/sticky-events
-    const stickyEvents = new StickyEvents({ stickySelector: '#grw-fav-sticky-trigger' });
-    const { stickySelector } = stickyEvents;
-    const elem = document.querySelector(stickySelector);
-    elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-
-    // return clean up handler
-    return () => {
-      elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-    };
-  }, [stickyChangeHandler]);
+  /*
+  * TODO: Comment out to prevent err >>> TypeError: Cannot read properties of null (reading 'bottom')
+  *       We need add style={{ position: 'relative }} to child elements if disable StickyEvents. see: use grep = "<Fab".
+  */
+  // const stickyChangeHandler = useCallback((event) => {
+  //   logger.debug('StickyEvents.CHANGE detected');
+
+  //   const newAnimateClasses = event.detail.isSticky ? 'animated fadeInUp faster' : 'animated fadeOut faster';
+  //   const newButtonClasses = event.detail.isSticky ? '' : 'disabled grw-pointer-events-none';
+
+  //   setAnimateClasses(newAnimateClasses);
+  //   setButtonClasses(newButtonClasses);
+  // }, []);
+
+  // // setup effect by sticky event
+  // useEffect(() => {
+  //   // sticky
+  //   // See: https://github.com/ryanwalters/sticky-events
+  //   const stickyEvents = new StickyEvents({ stickySelector: '#grw-fav-sticky-trigger' });
+  //   const { stickySelector } = stickyEvents;
+  //   const elem = document.querySelector(stickySelector);
+  //   elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+
+  //   // return clean up handler
+  //   return () => {
+  //     elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+  //   };
+  // }, [stickyChangeHandler]);
+
+  if (currentPath == null) {
+    return <></>;
+  }
 
-  function renderPageCreateButton() {
+  const renderPageCreateButton = () => {
     return (
       <>
         <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: '2.3rem', right: '4rem' }}>
@@ -70,7 +78,7 @@ const Fab = () => {
         </div>
       </>
     );
-  }
+  };
 
   return (
     <div className={`${styles['grw-fab']} grw-fab d-none d-md-block d-edit-none`} data-testid="grw-fab">
@@ -88,5 +96,3 @@ const Fab = () => {
   );
 
 };
-
-export default Fab;

+ 1 - 5
packages/app/src/components/Icons/CreatePageIcon.jsx → packages/app/src/components/Icons/CreatePageIcon.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 
-const CreatePageIcon = () => (
+export const CreatePageIcon = (): JSX.Element => (
   <svg
     xmlns="http://www.w3.org/2000/svg"
     viewBox="0 0 27 30"
@@ -19,8 +19,4 @@ const CreatePageIcon = () => (
     />
     <rect fillOpacity="0" width="27" height="27" />
   </svg>
-
 );
-
-
-export default CreatePageIcon;

+ 1 - 6
packages/app/src/components/Icons/ReturnTopIcon.jsx → packages/app/src/components/Icons/ReturnTopIcon.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 
-const ReturnTopIcon = () => (
+export const ReturnTopIcon = (): JSX.Element => (
   <svg
     xmlns="http://www.w3.org/2000/svg"
     viewBox="0 0 23 23"
@@ -11,10 +11,5 @@ const ReturnTopIcon = () => (
     />
     <path d="M22.35,4.61H.65a.65.65,0,0,1,0-1.3h21.7a.65.65,0,1,1,0,1.3Z" />
     <rect fillOpacity="0" width="23" height="23" />
-
   </svg>
-
 );
-
-
-export default ReturnTopIcon;

+ 0 - 217
packages/app/src/components/InstallerForm.jsx

@@ -1,217 +0,0 @@
-import React from 'react';
-
-import i18next from 'i18next';
-import { useTranslation, i18n } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import { i18n as i18nConfig } from '^/config/next-i18next.config';
-
-import { useCsrfToken } from '~/stores/context';
-
-class InstallerForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isValidUserName: true,
-      isSubmittingDisabled: false,
-    };
-    this.checkUserName = this.checkUserName.bind(this);
-
-    this.submitHandler = this.submitHandler.bind(this);
-  }
-
-  checkUserName(event) {
-    const axios = require('axios').create({
-      headers: {
-        'Content-Type': 'application/json',
-        'X-Requested-With': 'XMLHttpRequest',
-      },
-      responseType: 'json',
-    });
-    axios.get('/_api/v3/check-username', { params: { username: event.target.value } })
-      .then((res) => { return this.setState({ isValidUserName: res.data.valid }) });
-  }
-
-  submitHandler() {
-    if (this.state.isSubmittingDisabled) {
-      return;
-    }
-
-    this.setState({ isSubmittingDisabled: true });
-    setTimeout(() => {
-      this.setState({ isSubmittingDisabled: false });
-    }, 3000);
-  }
-
-  render() {
-    const { t } = this.props;
-    const hasErrorClass = this.state.isValidUserName ? '' : ' has-error';
-    const unavailableUserId = this.state.isValidUserName
-      ? ''
-      : <span><i className="icon-fw icon-ban" />{ this.props.t('installer.unavaliable_user_id') }</span>;
-
-    return (
-      <div data-testid="installerForm" className={`noLogin-dialog p-3 mx-auto${hasErrorClass}`}>
-        <div className="row">
-          <div className="col-md-12">
-            <p className="alert alert-success">
-              <strong>{ this.props.t('installer.create_initial_account') }</strong><br />
-              <small>{ this.props.t('installer.initial_account_will_be_administrator_automatically') }</small>
-            </p>
-          </div>
-        </div>
-        <div className="row">
-          <form role="form" action="/installer" method="post" id="register-form" className="col-md-12" onSubmit={this.submitHandler}>
-            <div className="dropdown mb-3">
-              <div className="d-flex dropdown-with-icon">
-                <i className="icon-bubbles border-0 rounded-0" />
-                <button
-                  type="button"
-                  className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
-                  id="dropdownLanguage"
-                  data-testid="dropdownLanguage"
-                  data-toggle="dropdown"
-                  aria-haspopup="true"
-                  aria-expanded="true"
-                >
-                  <span className="float-left">
-                    {t('meta.display_name')}
-                  </span>
-                </button>
-                <input
-                  type="hidden"
-                  name="registerForm[app:globalLang]"
-                />
-                <div className="dropdown-menu" aria-labelledby="dropdownLanguage">
-                  {
-                    i18nConfig.locales.map((locale) => {
-                      const fixedT = i18n.getFixedT(locale);
-                      i18n.loadLanguages(i18nConfig.locales);
-
-                      return (
-                        <button
-                          key={locale}
-                          data-testid={`dropdownLanguageMenu-${locale}`}
-                          className="dropdown-item"
-                          type="button"
-                          onClick={() => { i18next.changeLanguage(locale) }}
-                        >
-                          {fixedT('meta.display_name')}
-                        </button>
-                      );
-                    })
-                  }
-                </div>
-              </div>
-            </div>
-
-            <div className={`input-group mb-3${hasErrorClass}`}>
-              <div className="input-group-prepend">
-                <span className="input-group-text"><i className="icon-user" /></span>
-              </div>
-              <input
-                data-testid="tiUsername"
-                type="text"
-                className="form-control"
-                placeholder={this.props.t('User ID')}
-                name="registerForm[username]"
-                defaultValue={this.props.userName}
-                // onBlur={this.checkUserName} // need not to check username before installation -- 2020.07.24 Yuki Takei
-                required
-              />
-            </div>
-            <p className="form-text">{ unavailableUserId }</p>
-
-            <div className="input-group mb-3">
-              <div className="input-group-prepend">
-                <span className="input-group-text"><i className="icon-tag" /></span>
-              </div>
-              <input
-                data-testid="tiName"
-                type="text"
-                className="form-control"
-                placeholder={this.props.t('Name')}
-                name="registerForm[name]"
-                defaultValue={this.props.name}
-                required
-              />
-            </div>
-
-            <div className="input-group mb-3">
-              <div className="input-group-prepend">
-                <span className="input-group-text"><i className="icon-envelope" /></span>
-              </div>
-              <input
-                data-testid="tiEmail"
-                type="email"
-                className="form-control"
-                placeholder={this.props.t('Email')}
-                name="registerForm[email]"
-                defaultValue={this.props.email}
-                required
-              />
-            </div>
-
-            <div className="input-group mb-3">
-              <div className="input-group-prepend">
-                <span className="input-group-text"><i className="icon-lock" /></span>
-              </div>
-              <input
-                data-testid="tiPassword"
-                type="password"
-                className="form-control"
-                placeholder={this.props.t('Password')}
-                name="registerForm[password]"
-                required
-              />
-            </div>
-
-            <input type="hidden" name="_csrf" value={this.props.csrfToken} />
-
-            <div className="input-group mt-4 mb-3 d-flex justify-content-center">
-              <button
-                data-testid="btnSubmit"
-                type="submit"
-                className="btn-fill btn btn-register"
-                id="register"
-                disabled={this.state.isSubmittingDisabled}
-              >
-                <div className="eff"></div>
-                <span className="btn-label"><i className="icon-user-follow" /></span>
-                <span className="btn-label-text">{ this.props.t('Create') }</span>
-              </button>
-            </div>
-
-            <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>
-            </div>
-          </form>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-InstallerForm.propTypes = {
-  // i18next
-  t: PropTypes.func.isRequired,
-  // for input value
-  userName: PropTypes.string,
-  name: PropTypes.string,
-  email: PropTypes.string,
-  csrfToken: PropTypes.string,
-};
-
-const InstallerFormWrapperFC = (props) => {
-  const { t } = useTranslation();
-  const { data: csrfToken } = useCsrfToken();
-
-  return <InstallerForm t={t} csrfToken={csrfToken} {...props} />;
-};
-
-export default InstallerFormWrapperFC;

+ 231 - 0
packages/app/src/components/InstallerForm.tsx

@@ -0,0 +1,231 @@
+import {
+  FormEventHandler, memo, useCallback, useState,
+} from 'react';
+
+import i18next from 'i18next';
+import { useTranslation, i18n } from 'next-i18next';
+
+import { i18n as i18nConfig } from '^/config/next-i18next.config';
+
+import { toastError } from '~/client/util/apiNotification';
+import { apiv3Post } from '~/client/util/apiv3-client';
+
+const InstallerForm = memo((): JSX.Element => {
+  const { t } = useTranslation();
+
+  const [isValidUserName, setValidUserName] = useState(true);
+  const [isSubmittingDisabled, setSubmittingDisabled] = useState(false);
+
+  const checkUserName = useCallback(async(event) => {
+    const axios = require('axios').create({
+      headers: {
+        'Content-Type': 'application/json',
+        'X-Requested-With': 'XMLHttpRequest',
+      },
+      responseType: 'json',
+    });
+    const res = await axios.get('/_api/v3/check-username', { params: { username: event.target.value } });
+    setValidUserName(res.data.valid);
+  }, []);
+
+  const submitHandler: FormEventHandler = useCallback(async(e: any) => {
+    e.preventDefault();
+
+    if (isSubmittingDisabled) {
+      return;
+    }
+
+    setSubmittingDisabled(true);
+    setTimeout(() => {
+      setSubmittingDisabled(false);
+    }, 3000);
+
+    if (e.target.elements == null) {
+      return;
+    }
+
+    const formData = e.target.elements;
+
+    const {
+      'registerForm[username]': { value: username },
+      'registerForm[name]': { value: name },
+      'registerForm[email]': { value: email },
+      'registerForm[password]': { value: password },
+    } = formData;
+
+    const data = {
+      registerForm: {
+        username,
+        name,
+        email,
+        password,
+        'app:globalLang': formData['registerForm[app:globalLang]'].value,
+      },
+    };
+
+    try {
+      await apiv3Post('/installer', data);
+      window.location.href = '/';
+    }
+    catch (errs) {
+      const err = errs[0];
+      const code = err.code;
+
+      if (code === 'failed_to_login_after_install') {
+        toastError(t('installer.failed_to_login_after_install'));
+        setTimeout(() => { window.location.href = '/login' }, 700); // Wait 700 ms to show toastr
+      }
+
+      toastError(t('installer.failed_to_install'));
+    }
+  }, [isSubmittingDisabled, t]);
+
+  const hasErrorClass = isValidUserName ? '' : ' has-error';
+  const unavailableUserId = isValidUserName
+    ? ''
+    : <span><i className="icon-fw icon-ban" />{ t('installer.unavaliable_user_id') }</span>;
+
+  return (
+    <div data-testid="installerForm" className={`noLogin-dialog p-3 mx-auto${hasErrorClass}`}>
+      <div className="row">
+        <div className="col-md-12">
+          <p className="alert alert-success">
+            <strong>{ t('installer.create_initial_account') }</strong><br />
+            <small>{ t('installer.initial_account_will_be_administrator_automatically') }</small>
+          </p>
+        </div>
+      </div>
+      <div className="row">
+        <form role="form" id="register-form" className="col-md-12" onSubmit={submitHandler}>
+          <div className="dropdown mb-3">
+            <div className="d-flex dropdown-with-icon">
+              <i className="icon-bubbles border-0 rounded-0" />
+              <button
+                type="button"
+                className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
+                id="dropdownLanguage"
+                data-testid="dropdownLanguage"
+                data-toggle="dropdown"
+                aria-haspopup="true"
+                aria-expanded="true"
+              >
+                <span className="float-left">
+                  {t('meta.display_name')}
+                </span>
+              </button>
+              <input
+                type="hidden"
+                name="registerForm[app:globalLang]"
+              />
+              <div className="dropdown-menu" aria-labelledby="dropdownLanguage">
+                {
+                  i18nConfig.locales.map((locale) => {
+                    let fixedT;
+                    if (i18n != null) {
+                      fixedT = i18n.getFixedT(locale);
+                      i18n.loadLanguages(i18nConfig.locales);
+                    }
+
+                    return (
+                      <button
+                        key={locale}
+                        data-testid={`dropdownLanguageMenu-${locale}`}
+                        className="dropdown-item"
+                        type="button"
+                        onClick={() => { i18next.changeLanguage(locale) }}
+                      >
+                        {fixedT?.('meta.display_name')}
+                      </button>
+                    );
+                  })
+                }
+              </div>
+            </div>
+          </div>
+
+          <div className={`input-group mb-3${hasErrorClass}`}>
+            <div className="input-group-prepend">
+              <span className="input-group-text"><i className="icon-user" /></span>
+            </div>
+            <input
+              data-testid="tiUsername"
+              type="text"
+              className="form-control"
+              placeholder={t('User ID')}
+              name="registerForm[username]"
+              // onBlur={checkUserName} // need not to check username before installation -- 2020.07.24 Yuki Takei
+              required
+            />
+          </div>
+          <p className="form-text">{ unavailableUserId }</p>
+
+          <div className="input-group mb-3">
+            <div className="input-group-prepend">
+              <span className="input-group-text"><i className="icon-tag" /></span>
+            </div>
+            <input
+              data-testid="tiName"
+              type="text"
+              className="form-control"
+              placeholder={t('Name')}
+              name="registerForm[name]"
+              required
+            />
+          </div>
+
+          <div className="input-group mb-3">
+            <div className="input-group-prepend">
+              <span className="input-group-text"><i className="icon-envelope" /></span>
+            </div>
+            <input
+              data-testid="tiEmail"
+              type="email"
+              className="form-control"
+              placeholder={t('Email')}
+              name="registerForm[email]"
+              required
+            />
+          </div>
+
+          <div className="input-group mb-3">
+            <div className="input-group-prepend">
+              <span className="input-group-text"><i className="icon-lock" /></span>
+            </div>
+            <input
+              data-testid="tiPassword"
+              type="password"
+              className="form-control"
+              placeholder={t('Password')}
+              name="registerForm[password]"
+              required
+            />
+          </div>
+
+          <div className="input-group mt-4 mb-3 d-flex justify-content-center">
+            <button
+              data-testid="btnSubmit"
+              type="submit"
+              className="btn-fill btn btn-register"
+              id="register"
+              disabled={isSubmittingDisabled}
+            >
+              <div className="eff"></div>
+              <span className="btn-label"><i className="icon-user-follow" /></span>
+              <span className="btn-label-text">{ t('Create') }</span>
+            </button>
+          </div>
+
+          <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>
+          </div>
+        </form>
+      </div>
+    </div>
+  );
+});
+
+InstallerForm.displayName = 'InstallerForm';
+
+export default InstallerForm;

+ 4 - 4
packages/app/src/components/Layout/BasicLayout.tsx

@@ -7,7 +7,7 @@ import Sidebar from '../Sidebar';
 
 import { RawLayout } from './RawLayout';
 
-// const HotkeysManager = dynamic(() => import('../client/js/components/Hotkeys/HotkeysManager'), { ssr: false });
+const HotkeysManager = dynamic(() => import('../Hotkeys/HotkeysManager'), { ssr: false });
 // const PageCreateModal = dynamic(() => import('../client/js/components/PageCreateModal'), { ssr: false });
 const GrowiNavbarBottom = dynamic(() => import('../Navbar/GrowiNavbarBottom').then(mod => mod.GrowiNavbarBottom), { ssr: false });
 const ShortcutsModal = dynamic(() => import('../ShortcutsModal'), { ssr: false });
@@ -20,7 +20,7 @@ const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false
 const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
 const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal'), { ssr: false });
 // Fab
-const Fab = dynamic(() => import('../Fab'), { ssr: false });
+const Fab = dynamic(() => import('../Fab').then(mod => mod.Fab), { ssr: false });
 
 
 type Props = {
@@ -45,7 +45,7 @@ export const BasicLayout = ({
           <Sidebar />
         </div>
 
-        <div className="flex-fill mw-0">
+        <div className="flex-fill mw-0" style={{ position: 'relative' }}>
           {children}
         </div>
       </div>
@@ -58,7 +58,7 @@ export const BasicLayout = ({
       <PageRenameModal />
       <PagePresentationModal />
       <PageAccessoriesModal />
-      {/* <HotkeysManager /> */}
+      <HotkeysManager />
 
       <Fab />
 

+ 50 - 0
packages/app/src/components/Layout/ShareLinkLayout.tsx

@@ -0,0 +1,50 @@
+import React, { ReactNode } from 'react';
+
+import dynamic from 'next/dynamic';
+
+import { GrowiNavbar } from '../Navbar/GrowiNavbar';
+
+import { RawLayout } from './RawLayout';
+
+const PageCreateModal = dynamic(() => import('../PageCreateModal'), { ssr: false });
+const GrowiNavbarBottom = dynamic(() => import('../Navbar/GrowiNavbarBottom').then(mod => mod.GrowiNavbarBottom), { ssr: false });
+const ShortcutsModal = dynamic(() => import('../ShortcutsModal'), { ssr: false });
+const SystemVersion = dynamic(() => import('../SystemVersion'), { ssr: false });
+
+// Fab
+const Fab = dynamic(() => import('../Fab').then(mod => mod.Fab), { ssr: false });
+
+
+type Props = {
+  title: string
+  className?: string,
+  expandContainer?: boolean,
+  children?: ReactNode
+}
+
+export const ShareLinkLayout = ({
+  children, title, className, expandContainer,
+}: Props): JSX.Element => {
+
+  const myClassName = `${className ?? ''} ${expandContainer ? 'growi-layout-fluid' : ''}`;
+
+  return (
+    <RawLayout title={title} className={myClassName}>
+      <GrowiNavbar />
+
+      <div className="page-wrapper d-flex d-print-block">
+        <div className="flex-fill mw-0" style={{ position: 'relative' }}>
+          {children}
+        </div>
+      </div>
+
+      <GrowiNavbarBottom />
+
+      <Fab />
+
+      <ShortcutsModal />
+      <PageCreateModal />
+      <SystemVersion showShortcutsButton />
+    </RawLayout>
+  );
+};

+ 4 - 4
packages/app/src/components/LikeButtons.module.scss

@@ -1,11 +1,11 @@
 @use '~/styles/bootstrap/init' as bs;
 
-.btn-like {
-  :global {
+.btn-group-like :global {
+  .btn-like {
     box-shadow: none !important;
-  }
-  &:global {
+
     @include bs.button-outline-variant(rgba(bs.$secondary, 50%), lighten(bs.$red, 15%), rgba(lighten(bs.$red, 10%), 0.15), rgba(lighten(bs.$red, 10%), 0.5));
+
     &:not(:disabled):not(.disabled):active,
     &:not(:disabled):not(.disabled).active {
       color: lighten(bs.$red, 15%);

+ 2 - 2
packages/app/src/components/LikeButtons.tsx

@@ -45,12 +45,12 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
   }, [isGuestUser, isLiked]);
 
   return (
-    <div className="btn-group" role="group" aria-label="Like buttons">
+    <div className={`btn-group btn-group-like ${styles['btn-group-like']}`} role="group" aria-label="Like buttons">
       <button
         type="button"
         id="like-button"
         onClick={onLikeClicked}
-        className={`shadow-none btn btn-like ${styles['btn-like']} border-0
+        className={`shadow-none btn btn-like border-0
             ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
         <i className={`fa ${isLiked ? 'fa-heart' : 'fa-heart-o'}`}></i>

+ 0 - 365
packages/app/src/components/LoginForm.jsx

@@ -1,365 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import ReactCardFlip from 'react-card-flip';
-
-import { useCsrfToken } from '~/stores/context';
-
-class LoginForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isRegistering: false,
-    };
-
-    this.switchForm = this.switchForm.bind(this);
-    this.handleLoginWithExternalAuth = this.handleLoginWithExternalAuth.bind(this);
-    this.renderLocalOrLdapLoginForm = this.renderLocalOrLdapLoginForm.bind(this);
-    this.renderExternalAuthLoginForm = this.renderExternalAuthLoginForm.bind(this);
-    this.renderExternalAuthInput = this.renderExternalAuthInput.bind(this);
-    this.renderRegisterForm = this.renderRegisterForm.bind(this);
-
-    const { hash } = window.location;
-    if (hash === '#register') {
-      this.state.isRegistering = true;
-    }
-  }
-
-  switchForm() {
-    this.setState({ isRegistering: !this.state.isRegistering });
-  }
-
-  handleLoginWithExternalAuth(e) {
-    const auth = e.currentTarget.id;
-    window.location.href = `/passport/${auth}`;
-  }
-
-  renderLocalOrLdapLoginForm() {
-    const { t, csrfToken, isLdapStrategySetup } = this.props;
-
-    return (
-      <form role="form" action="/login" method="post">
-        <div className="input-group">
-          <div className="input-group-prepend">
-            <span className="input-group-text">
-              <i className="icon-user"></i>
-            </span>
-          </div>
-          <input type="text" className="form-control rounded-0" data-testid="tiUsernameForLogin" placeholder="Username or E-mail" name="loginForm[username]" />
-          {isLdapStrategySetup && (
-            <div className="input-group-append">
-              <small className="input-group-text text-success">
-                <i className="icon-fw icon-check"></i> LDAP
-              </small>
-            </div>
-          )}
-        </div>
-
-        <div className="input-group">
-          <div className="input-group-prepend">
-            <span className="input-group-text">
-              <i className="icon-lock"></i>
-            </span>
-          </div>
-          <input type="password" className="form-control rounded-0" data-testid="tiPasswordForLogin" placeholder="Password" name="loginForm[password]" />
-        </div>
-
-        <div className="input-group my-4">
-          <input type="hidden" name="_csrf" value={csrfToken} />
-          <button type="submit" id="login" className="btn btn-fill rounded-0 login mx-auto" data-testid="btnSubmitForLogin">
-            <div className="eff"></div>
-            <span className="btn-label">
-              <i className="icon-login"></i>
-            </span>
-            <span className="btn-label-text">{t('Sign in')}</span>
-          </button>
-        </div>
-      </form>
-    );
-  }
-
-  renderExternalAuthInput(auth) {
-    const { t } = this.props;
-    const authIconNames = {
-      google: 'google',
-      github: 'github',
-      facebook: 'facebook',
-      twitter: 'twitter',
-      oidc: 'openid',
-      saml: 'key',
-      basic: 'lock',
-    };
-
-    return (
-      <div key={auth} className="col-6 my-2">
-        <button type="button" className="btn btn-fill rounded-0" id={auth} onClick={this.handleLoginWithExternalAuth}>
-          <div className="eff"></div>
-          <span className="btn-label">
-            <i className={`fa fa-${authIconNames[auth]}`}></i>
-          </span>
-          <span className="btn-label-text">{t('Sign in')}</span>
-        </button>
-        <div className="small text-right">by {auth} Account</div>
-      </div>
-    );
-  }
-
-  renderExternalAuthLoginForm() {
-    const { isLocalStrategySetup, isLdapStrategySetup, objOfIsExternalAuthEnableds } = this.props;
-    const isExternalAuthCollapsible = isLocalStrategySetup || isLdapStrategySetup;
-    const collapsibleClass = isExternalAuthCollapsible ? 'collapse collapse-external-auth' : '';
-
-    return (
-      <>
-        <div className="grw-external-auth-form border-top border-bottom">
-          <div id="external-auth" className={`external-auth ${collapsibleClass}`}>
-            <div className="row mt-2">
-              {Object.keys(objOfIsExternalAuthEnableds).map((auth) => {
-                if (!objOfIsExternalAuthEnableds[auth]) {
-                  return;
-                }
-                return this.renderExternalAuthInput(auth);
-              })}
-            </div>
-          </div>
-        </div>
-        <div className="text-center">
-          <button
-            type="button"
-            className="btn btn-secondary btn-external-auth-tab btn-sm rounded-0 mb-3"
-            data-toggle={isExternalAuthCollapsible ? 'collapse' : ''}
-            data-target="#external-auth"
-            aria-expanded="false"
-            aria-controls="external-auth"
-          >
-            External Auth
-          </button>
-        </div>
-      </>
-    );
-  }
-
-  renderRegisterForm() {
-    const {
-      t,
-      // appContainer,
-      csrfToken,
-      isEmailAuthenticationEnabled,
-      username,
-      name,
-      email,
-      registrationMode,
-      registrationWhiteList,
-      isMailerSetup,
-    } = this.props;
-
-    let registerAction = '/register';
-
-    let submitText = t('Sign up');
-    if (isEmailAuthenticationEnabled) {
-      registerAction = '/user-activation/register';
-      submitText = t('page_register.send_email');
-    }
-
-    return (
-      <React.Fragment>
-        {registrationMode === 'Restricted' && (
-          <p className="alert alert-warning">
-            {t('page_register.notice.restricted')}
-            <br />
-            {t('page_register.notice.restricted_defail')}
-          </p>
-        )}
-        { (!isMailerSetup && isEmailAuthenticationEnabled) && (
-          <p className="alert alert-danger">
-            <span>{t('security_settings.Local.please_enable_mailer')}</span>
-          </p>
-        )}
-
-        <form role="form" action={registerAction} method="post" id="register-form">
-
-          {!isEmailAuthenticationEnabled && (
-            <div>
-              <div className="input-group" id="input-group-username">
-                <div className="input-group-prepend">
-                  <span className="input-group-text">
-                    <i className="icon-user"></i>
-                  </span>
-                </div>
-                <input
-                  type="text"
-                  className="form-control rounded-0"
-                  placeholder={t('User ID')}
-                  name="registerForm[username]"
-                  defaultValue={username}
-                  required
-                />
-              </div>
-              <p className="form-text text-danger">
-                <span id="help-block-username"></span>
-              </p>
-              <div className="input-group">
-                <div className="input-group-prepend">
-                  <span className="input-group-text">
-                    <i className="icon-tag"></i>
-                  </span>
-                </div>
-                <input type="text" className="form-control rounded-0" placeholder={t('Name')} name="registerForm[name]" defaultValue={name} required />
-              </div>
-            </div>
-          )}
-
-          <div className="input-group">
-            <div className="input-group-prepend">
-              <span className="input-group-text">
-                <i className="icon-envelope"></i>
-              </span>
-            </div>
-            <input type="email" className="form-control rounded-0" placeholder={t('Email')} name="registerForm[email]" defaultValue={email} required />
-          </div>
-
-          {registrationWhiteList.length > 0 && (
-            <>
-              <p className="form-text">{t('page_register.form_help.email')}</p>
-              <ul>
-                {registrationWhiteList.map((elem) => {
-                  return (
-                    <li key={elem}>
-                      <code>{elem}</code>
-                    </li>
-                  );
-                })}
-              </ul>
-            </>
-          )}
-
-          {!isEmailAuthenticationEnabled && (
-            <div>
-              <div className="input-group">
-                <div className="input-group-prepend">
-                  <span className="input-group-text">
-                    <i className="icon-lock"></i>
-                  </span>
-                </div>
-                <input type="password" className="form-control rounded-0" placeholder={t('Password')} name="registerForm[password]" required />
-              </div>
-            </div>
-          )}
-
-          <div className="input-group justify-content-center my-4">
-            <input type="hidden" name="_csrf" value={csrfToken} />
-            <button type="submit" className="btn btn-fill rounded-0" id="register" disabled={(!isMailerSetup && isEmailAuthenticationEnabled)}>
-              <div className="eff"></div>
-              <span className="btn-label">
-                <i className="icon-user-follow"></i>
-              </span>
-              <span className="btn-label-text">{submitText}</span>
-            </button>
-          </div>
-        </form>
-
-        <div className="border-bottom"></div>
-
-        <div className="row">
-          <div className="text-right col-12 mt-2 py-2">
-            <a href="#login" id="login" className="link-switch" onClick={this.switchForm}>
-              <i className="icon-fw icon-login"></i>
-              {t('Sign in is here')}
-            </a>
-          </div>
-        </div>
-      </React.Fragment>
-    );
-  }
-
-  render() {
-    const {
-      t,
-      isLocalStrategySetup,
-      isLdapStrategySetup,
-      isRegistrationEnabled,
-      isPasswordResetEnabled,
-      objOfIsExternalAuthEnableds,
-    } = this.props;
-
-
-    const isLocalOrLdapStrategiesEnabled = isLocalStrategySetup || isLdapStrategySetup;
-    // const isSomeExternalAuthEnabled = Object.values(objOfIsExternalAuthEnableds).some(elem => elem);
-    const isSomeExternalAuthEnabled = true;
-
-    return (
-      <div className="noLogin-dialog mx-auto" id="noLogin-dialog">
-        <div className="row mx-0">
-          <div className="col-12">
-            <ReactCardFlip isFlipped={this.state.isRegistering} flipDirection="horizontal" cardZIndex="3">
-              <div className="front">
-                {isLocalOrLdapStrategiesEnabled && this.renderLocalOrLdapLoginForm()}
-                {isSomeExternalAuthEnabled && this.renderExternalAuthLoginForm()}
-                {isLocalOrLdapStrategiesEnabled && isPasswordResetEnabled && (
-                  <div className="text-right mb-2">
-                    <a href="/forgot-password" className="d-block link-switch">
-                      <i className="icon-key"></i> {t('forgot_password.forgot_password')}
-                    </a>
-                  </div>
-                )}
-                {isRegistrationEnabled && (
-                  <div className="text-right mb-2">
-                    <a href="#register" id="register" className="link-switch" onClick={this.switchForm}>
-                      <i className="ti ti-check-box"></i> {t('Sign up is here')}
-                    </a>
-                  </div>
-                )}
-              </div>
-              <div className="back">
-                {isRegistrationEnabled && this.renderRegisterForm()}
-              </div>
-            </ReactCardFlip>
-          </div>
-        </div>
-        <a href="https://growi.org" className="link-growi-org pl-3">
-          <span className="growi">GROWI</span>.<span className="org">ORG</span>
-        </a>
-      </div>
-    );
-  }
-
-}
-
-LoginForm.propTypes = {
-  // i18next
-  t: PropTypes.func.isRequired,
-  // appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  csrfToken: PropTypes.string,
-  isRegistering: PropTypes.bool,
-  username: PropTypes.string,
-  name: PropTypes.string,
-  email: PropTypes.string,
-  isRegistrationEnabled: PropTypes.bool,
-  registrationMode: PropTypes.string,
-  registrationWhiteList: PropTypes.array,
-  isPasswordResetEnabled: PropTypes.bool,
-  isEmailAuthenticationEnabled: PropTypes.bool,
-  isLocalStrategySetup: PropTypes.bool,
-  isLdapStrategySetup: PropTypes.bool,
-  objOfIsExternalAuthEnableds: PropTypes.object,
-  isMailerSetup: PropTypes.bool,
-};
-
-const LoginFormWrapperFC = (props) => {
-  const { t } = useTranslation();
-  const { data: csrfToken } = useCsrfToken();
-
-  return <LoginForm t={t} csrfToken={csrfToken} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-// const LoginFormWrapper = withUnstatedContainers(LoginFormWrapperFC, [AppContainer]);
-
-// export default LoginForm;
-export default LoginFormWrapperFC;

+ 441 - 0
packages/app/src/components/LoginForm.tsx

@@ -0,0 +1,441 @@
+import React, {
+  useState, useEffect, useCallback,
+} from 'react';
+
+import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
+import ReactCardFlip from 'react-card-flip';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+
+type LoginFormProps = {
+  username?: string,
+  name?: string,
+  email?: string,
+  isRegistrationEnabled: boolean,
+  isEmailAuthenticationEnabled: boolean,
+  registrationMode?: string,
+  registrationWhiteList: string[],
+  isPasswordResetEnabled: boolean,
+  isLocalStrategySetup: boolean,
+  isLdapStrategySetup: boolean,
+  objOfIsExternalAuthEnableds?: any,
+  isMailerSetup?: boolean
+}
+export const LoginForm = (props: LoginFormProps): JSX.Element => {
+  const { t } = useTranslation();
+  const router = useRouter();
+
+  const {
+    isLocalStrategySetup, isLdapStrategySetup, isPasswordResetEnabled, isRegistrationEnabled,
+    isEmailAuthenticationEnabled, registrationMode, registrationWhiteList, isMailerSetup, objOfIsExternalAuthEnableds,
+  } = props;
+  const isLocalOrLdapStrategiesEnabled = isLocalStrategySetup || isLdapStrategySetup;
+  const isSomeExternalAuthEnabled = Object.values(objOfIsExternalAuthEnableds).some(elem => elem);
+
+  // states
+  const [isRegistering, setIsRegistering] = useState(false);
+  // For Login
+  const [usernameForLogin, setUsernameForLogin] = useState('');
+  const [passwordForLogin, setPasswordForLogin] = useState('');
+  const [loginErrors, setLoginErrors] = useState<Error[]>([]);
+  // For Register
+  const [usernameForRegister, setUsernameForRegister] = useState('');
+  const [nameForRegister, setNameForRegister] = useState('');
+  const [emailForRegister, setEmailForRegister] = useState('');
+  const [passwordForRegister, setPasswordForRegister] = useState('');
+  const [registerErrors, setRegisterErrors] = useState<Error[]>([]);
+
+  useEffect(() => {
+    const { hash } = window.location;
+    if (hash === '#register') {
+      setIsRegistering(true);
+    }
+  }, []);
+
+  // functions
+  const handleLoginWithExternalAuth = useCallback((e) => {
+    const auth = e.currentTarget.id;
+
+    window.location.href = `/passport/${auth}`;
+  }, []);
+
+  const handleLoginWithLocalSubmit = useCallback(async(e) => {
+    e.preventDefault();
+
+    const loginForm = {
+      username: usernameForLogin,
+      password: passwordForLogin,
+    };
+
+    try {
+      const res = await apiv3Post('/login', { loginForm });
+      const { redirectTo } = res.data;
+      router.push(redirectTo);
+    }
+    catch (err) {
+      setLoginErrors(err);
+    }
+    return;
+
+  }, [passwordForLogin, router, usernameForLogin]);
+
+  const renderLocalOrLdapLoginForm = useCallback(() => {
+    const { isLdapStrategySetup } = props;
+    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>
+          )
+        }
+        <form role="form" onSubmit={handleLoginWithLocalSubmit} id="login-form">
+          <div className="input-group">
+            <div className="input-group-prepend">
+              <span className="input-group-text">
+                <i className="icon-user"></i>
+              </span>
+            </div>
+            <input type="text" className="form-control rounded-0" data-testid="tiUsernameForLogin" placeholder="Username or E-mail"
+              onChange={(e) => { setUsernameForLogin(e.target.value) }} name="usernameForLogin" />
+            {isLdapStrategySetup && (
+              <div className="input-group-append">
+                <small className="input-group-text text-success">
+                  <i className="icon-fw icon-check"></i> LDAP
+                </small>
+              </div>
+            )}
+          </div>
+
+          <div className="input-group">
+            <div className="input-group-prepend">
+              <span className="input-group-text">
+                <i className="icon-lock"></i>
+              </span>
+            </div>
+            <input type="password" className="form-control rounded-0" data-testid="tiPasswordForLogin" placeholder="Password"
+              onChange={(e) => { setPasswordForLogin(e.target.value) }} name="passwordForLogin" />
+          </div>
+
+          <div className="input-group my-4">
+            <button type="submit" id="login" className="btn btn-fill rounded-0 login mx-auto" data-testid="btnSubmitForLogin">
+              <div className="eff"></div>
+              <span className="btn-label">
+                <i className="icon-login"></i>
+              </span>
+              <span className="btn-label-text">{t('Sign in')}</span>
+            </button>
+          </div>
+        </form>
+      </>
+    );
+  }, [handleLoginWithLocalSubmit, loginErrors, props, t]);
+
+  const renderExternalAuthInput = useCallback((auth) => {
+    const authIconNames = {
+      google: 'google',
+      github: 'github',
+      facebook: 'facebook',
+      twitter: 'twitter',
+      oidc: 'openid',
+      saml: 'key',
+      basic: 'lock',
+    };
+
+    return (
+      <div key={auth} className="col-6 my-2">
+        <button type="button" className="btn btn-fill rounded-0" id={auth} onClick={handleLoginWithExternalAuth}>
+          <div className="eff"></div>
+          <span className="btn-label">
+            <i className={`fa fa-${authIconNames[auth]}`}></i>
+          </span>
+          <span className="btn-label-text">{t('Sign in')}</span>
+        </button>
+        <div className="small text-right">by {auth} Account</div>
+      </div>
+    );
+  }, [handleLoginWithExternalAuth, t]);
+
+  const renderExternalAuthLoginForm = useCallback(() => {
+    const { isLocalStrategySetup, isLdapStrategySetup, objOfIsExternalAuthEnableds } = props;
+    const isExternalAuthCollapsible = isLocalStrategySetup || isLdapStrategySetup;
+    const collapsibleClass = isExternalAuthCollapsible ? 'collapse collapse-external-auth' : '';
+
+    return (
+      <>
+        <div className="grw-external-auth-form border-top border-bottom">
+          <div id="external-auth" className={`external-auth ${collapsibleClass}`}>
+            <div className="row mt-2">
+              {Object.keys(objOfIsExternalAuthEnableds).map((auth) => {
+                if (!objOfIsExternalAuthEnableds[auth]) {
+                  return;
+                }
+                return renderExternalAuthInput(auth);
+              })}
+            </div>
+          </div>
+        </div>
+        <div className="text-center">
+          <button
+            type="button"
+            className="btn btn-secondary btn-external-auth-tab btn-sm rounded-0 mb-3"
+            data-toggle={isExternalAuthCollapsible ? 'collapse' : ''}
+            data-target="#external-auth"
+            aria-expanded="false"
+            aria-controls="external-auth"
+          >
+            External Auth
+          </button>
+        </div>
+      </>
+    );
+  }, [props, renderExternalAuthInput]);
+
+  const handleRegisterFormSubmit = useCallback(async(e, requestPath) => {
+    e.preventDefault();
+
+    const registerForm = {
+      username: usernameForRegister,
+      name: nameForRegister,
+      email: emailForRegister,
+      password: passwordForRegister,
+    };
+    try {
+      const res = await apiv3Post(requestPath, { registerForm });
+      const { redirectTo } = res.data;
+      router.push(redirectTo);
+    }
+    catch (err) {
+      // Execute if error exists
+      if (err != null || err.length > 0) {
+        setRegisterErrors(err);
+      }
+    }
+    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([]);
+  }, [registerErrors.length]);
+
+  const switchForm = useCallback(() => {
+    setIsRegistering(!isRegistering);
+    resetLoginErrors();
+    resetRegisterErrors();
+  }, [isRegistering, resetLoginErrors, resetRegisterErrors]);
+
+  const renderRegisterForm = useCallback(() => {
+    let registerAction = '/register';
+
+    let submitText = t('Sign up');
+    if (isEmailAuthenticationEnabled) {
+      registerAction = '/user-activation/register';
+      submitText = t('page_register.send_email');
+    }
+
+    return (
+      <React.Fragment>
+        {registrationMode === 'Restricted' && (
+          <p className="alert alert-warning">
+            {t('page_register.notice.restricted')}
+            <br />
+            {t('page_register.notice.restricted_defail')}
+          </p>
+        )}
+        { (!isMailerSetup && isEmailAuthenticationEnabled) && (
+          <p className="alert alert-danger">
+            <span>{t('security_settings.Local.please_enable_mailer')}</span>
+          </p>
+        )}
+
+        {
+          registerErrors != null && registerErrors.length > 0 && (
+            <p className="alert alert-danger">
+              {registerErrors.map((err, index) => {
+                return (
+                  <span key={index}>
+                    {t(err.message)}<br/>
+                  </span>
+                );
+              })}
+            </p>
+          )
+        }
+
+        <form role="form" onSubmit={e => handleRegisterFormSubmit(e, registerAction) } id="register-form">
+
+          {!isEmailAuthenticationEnabled && (
+            <div>
+              <div className="input-group" id="input-group-username">
+                <div className="input-group-prepend">
+                  <span className="input-group-text">
+                    <i className="icon-user"></i>
+                  </span>
+                </div>
+                {/* username */}
+                <input
+                  type="text"
+                  className="form-control rounded-0"
+                  onChange={(e) => { setUsernameForRegister(e.target.value) }}
+                  placeholder={t('User ID')}
+                  name="username"
+                  defaultValue={props.username}
+                  required
+                />
+              </div>
+              <p className="form-text text-danger">
+                <span id="help-block-username"></span>
+              </p>
+              <div className="input-group">
+                <div className="input-group-prepend">
+                  <span className="input-group-text">
+                    <i className="icon-tag"></i>
+                  </span>
+                </div>
+                {/* name */}
+                <input type="text"
+                  className="form-control rounded-0"
+                  onChange={(e) => { setNameForRegister(e.target.value) }}
+                  placeholder={t('Name')}
+                  name="name"
+                  defaultValue={props.name}
+                  required />
+              </div>
+            </div>
+          )}
+
+          <div className="input-group">
+            <div className="input-group-prepend">
+              <span className="input-group-text">
+                <i className="icon-envelope"></i>
+              </span>
+            </div>
+            {/* email */}
+            <input type="email"
+              className="form-control rounded-0"
+              onChange={(e) => { setEmailForRegister(e.target.value) }}
+              placeholder={t('Email')}
+              name="email"
+              defaultValue={props.email}
+              required
+            />
+          </div>
+
+          {registrationWhiteList.length > 0 && (
+            <>
+              <p className="form-text">{t('page_register.form_help.email')}</p>
+              <ul>
+                {registrationWhiteList.map((elem) => {
+                  return (
+                    <li key={elem}>
+                      <code>{elem}</code>
+                    </li>
+                  );
+                })}
+              </ul>
+            </>
+          )}
+
+          {!isEmailAuthenticationEnabled && (
+            <div>
+              <div className="input-group">
+                <div className="input-group-prepend">
+                  <span className="input-group-text">
+                    <i className="icon-lock"></i>
+                  </span>
+                </div>
+                {/* Password */}
+                <input type="password"
+                  className="form-control rounded-0"
+                  onChange={(e) => { setPasswordForRegister(e.target.value) }}
+                  placeholder={t('Password')}
+                  name="password"
+                  required />
+              </div>
+            </div>
+          )}
+
+          {/* Sign up button (submit) */}
+          <div className="input-group justify-content-center my-4">
+            <button
+              className="btn btn-fill rounded-0"
+              id="register"
+              disabled={(!isMailerSetup && isEmailAuthenticationEnabled)}
+            >
+              <div className="eff"></div>
+              <span className="btn-label">
+                <i className="icon-user-follow"></i>
+              </span>
+              <span className="btn-label-text">{submitText}</span>
+            </button>
+          </div>
+        </form>
+
+        <div className="border-bottom"></div>
+
+        <div className="row">
+          <div className="text-right col-12 mt-2 py-2">
+            <a href="#login" id="login" className="link-switch" onClick={switchForm}>
+              <i className="icon-fw icon-login"></i>
+              {t('Sign in is here')}
+            </a>
+          </div>
+        </div>
+      </React.Fragment>
+    );
+  }, [handleRegisterFormSubmit, isEmailAuthenticationEnabled, isMailerSetup,
+      props.email, props.name, props.username,
+      registerErrors, registrationMode, registrationWhiteList, switchForm, t]);
+
+  return (
+    <div className="noLogin-dialog mx-auto" id="noLogin-dialog">
+      <div className="row mx-0">
+        <div className="col-12">
+          <ReactCardFlip isFlipped={isRegistering} flipDirection="horizontal" cardZIndex="3">
+            <div className="front">
+              {isLocalOrLdapStrategiesEnabled && renderLocalOrLdapLoginForm()}
+              {isSomeExternalAuthEnabled && renderExternalAuthLoginForm()}
+              {isLocalOrLdapStrategiesEnabled && isPasswordResetEnabled && (
+                <div className="text-right mb-2">
+                  <a href="/forgot-password" className="d-block link-switch">
+                    <i className="icon-key"></i> {t('forgot_password.forgot_password')}
+                  </a>
+                </div>
+              )}
+              {/* Sign up link */}
+              {isRegistrationEnabled && (
+                <div className="text-right mb-2">
+                  <a href="#register" id="register" className="link-switch" onClick={switchForm}>
+                    <i className="ti ti-check-box"></i> {t('Sign up is here')}
+                  </a>
+                </div>
+              )}
+            </div>
+            <div className="back">
+              {/* Register form for /login#register */}
+              {isRegistrationEnabled && renderRegisterForm()}
+            </div>
+          </ReactCardFlip>
+        </div>
+      </div>
+      <a href="https://growi.org" className="link-growi-org pl-3">
+        <span className="growi">GROWI</span>.<span className="org">ORG</span>
+      </a>
+    </div>
+  );
+
+};

+ 0 - 1
packages/app/src/components/Me/BasicInfoSettings.tsx

@@ -109,7 +109,6 @@ export const BasicInfoSettings = (): JSX.Element => {
             i18nConfig.locales.map((locale) => {
               if (i18n == null) { return }
               const fixedT = i18n.getFixedT(locale);
-              i18n.loadLanguages(i18nConfig.locales);
 
               return (
                 <div key={locale} className="custom-control custom-radio custom-control-inline">

+ 69 - 52
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -1,6 +1,6 @@
 import React, { useState, useEffect, useCallback } from 'react';
 
-import { isPopulated } from '@growi/core';
+import { isPopulated, IUser } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import { DropdownItem } from 'reactstrap';
@@ -9,13 +9,13 @@ import { exportAsMarkdown } from '~/client/services/page-operation';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
 import {
-  IPageToRenameWithMeta, IPageWithMeta, IPageInfoForEntity, IPageHasId,
+  IPageToRenameWithMeta, IPageWithMeta, IPageInfoForEntity,
 } from '~/interfaces/page';
 import { IResTagsUpdateApiv1 } from '~/interfaces/tag';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import {
-  useCurrentPageId,
-  useCurrentPathname, useIsNotFound,
+  useCurrentPageId, useCurrentPathname,
+  useIsNotFound,
   useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId, useTemplateTagData,
 } from '~/stores/context';
 import { usePageTagsForEditors } from '~/stores/editor';
@@ -39,19 +39,27 @@ import { Skelton } from '../Skelton';
 import { GrowiSubNavigation } from './GrowiSubNavigation';
 import { SubNavButtonsProps } from './SubNavButtons';
 
-
+import AuthorInfoStyles from './AuthorInfo.module.scss';
 import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
 
 
+const AuthorInfoSkelton = () => <Skelton additionalClass={`${AuthorInfoStyles['grw-author-info-skelton']} py-1`} />;
+
+
 const PageEditorModeManager = dynamic(
   () => import('./PageEditorModeManager'),
   { ssr: false, loading: () => <Skelton additionalClass={`${PageEditorModeManagerStyles['grw-page-editor-mode-manager-skelton']}`} /> },
 );
+// TODO: If enable skeleton, we get hydration error when create a page from PageCreateModal
+// { ssr: false, loading: () => <Skelton additionalClass='btn-skelton py-2' /> },
 const SubNavButtons = dynamic<SubNavButtonsProps>(
   () => import('./SubNavButtons').then(mod => mod.SubNavButtons),
-  { ssr: false, loading: () => <Skelton additionalClass='btn-skelton py-2' /> },
+  { ssr: false, loading: () => <></> },
 );
-
+const AuthorInfo = dynamic(() => import('./AuthorInfo'), {
+  ssr: false,
+  loading: AuthorInfoSkelton,
+});
 
 type AdditionalMenuItemsProps = {
   pageId: string,
@@ -177,10 +185,10 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { data: pageId } = useCurrentPageId();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentUser } = useCurrentUser();
+  const { data: isNotFound } = useIsNotFound();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: shareLinkId } = useShareLinkId();
-  const { data: isNotFound } = useIsNotFound();
 
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToShowTagLabel } = useIsAbleToShowTagLabel();
@@ -295,15 +303,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   }, []);
 
 
-  const ControlComponents = useCallback(() => {
-    if (currentPage == null || pageId == null) {
-      return <></>;
-    }
-
-    function onPageEditorModeButtonClicked(viewType) {
-      mutateEditorMode(viewType);
-    }
-
+  const RightComponent = useCallback(() => {
     const additionalMenuItemsRenderer = () => {
       if (revisionId == null || pageId == null) {
         return <></>;
@@ -317,35 +317,56 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
         />
       );
     };
+
     return (
       <>
-        <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">
-              <SubNavButtons
-                isCompactMode={isCompactMode}
-                pageId={pageId}
-                revisionId={revisionId}
-                shareLinkId={shareLinkId}
-                path={path}
-                disableSeenUserInfoPopover={isSharedUser}
-                showPageControlDropdown={isAbleToShowPageManagement}
-                additionalMenuItemRenderer={additionalMenuItemsRenderer}
-                onClickDuplicateMenuItem={duplicateItemClickedHandler}
-                onClickRenameMenuItem={renameItemClickedHandler}
-                onClickDeleteMenuItem={deleteItemClickedHandler}
+        <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">
+                { pageId != null && (
+                  <SubNavButtons
+                    isCompactMode={isCompactMode}
+                    pageId={pageId}
+                    revisionId={revisionId}
+                    shareLinkId={shareLinkId}
+                    path={path}
+                    disableSeenUserInfoPopover={isSharedUser}
+                    showPageControlDropdown={isAbleToShowPageManagement}
+                    additionalMenuItemRenderer={additionalMenuItemsRenderer}
+                    onClickDuplicateMenuItem={duplicateItemClickedHandler}
+                    onClickRenameMenuItem={renameItemClickedHandler}
+                    onClickDeleteMenuItem={deleteItemClickedHandler}
+                  />
+                ) }
+              </div>
+            ) }
+            {isAbleToShowPageEditorModeManager && (
+              <PageEditorModeManager
+                onPageEditorModeButtonClicked={viewType => mutateEditorMode(viewType)}
+                isBtnDisabled={isGuestUser}
+                editorMode={editorMode}
               />
-            </div>
+            )}
+          </div>
+          { (isAbleToShowPageAuthors && !isCompactMode) && (
+            <ul className={`${AuthorInfoStyles['grw-author-info']} text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3`}>
+              <li className="pb-1">
+                { currentPage != null
+                  ? <AuthorInfo user={currentPage.creator as IUser} date={currentPage.createdAt} locate="subnav" />
+                  : <AuthorInfoSkelton />
+                }
+              </li>
+              <li className="mt-1 pt-1 border-top">
+                { currentPage != null
+                  ? <AuthorInfo user={currentPage.lastUpdateUser as IUser} date={currentPage.updatedAt} mode="update" locate="subnav" />
+                  : <AuthorInfoSkelton />
+                }
+              </li>
+            </ul>
           ) }
-          {isAbleToShowPageEditorModeManager && (
-            <PageEditorModeManager
-              onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}
-              isBtnDisabled={isGuestUser}
-              editorMode={editorMode}
-            />
-          )}
         </div>
+
         {path != null && currentUser != null && (
           <CreateTemplateModal
             path={path}
@@ -356,29 +377,25 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       </>
     );
   // eslint-disable-next-line max-len
-  }, [currentPage, currentUser, pageId, revisionId, shareLinkId, path, editorMode, isCompactMode, isViewMode, isSharedUser, isAbleToShowPageManagement, isAbleToShowPageEditorModeManager, isLinkSharingDisabled, isGuestUser, isPageTemplateModalShown, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, mutateEditorMode, templateMenuItemClickHandler]);
+  }, [isCompactMode, isViewMode, pageId, revisionId, shareLinkId, path, isSharedUser, isAbleToShowPageManagement, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, isAbleToShowPageEditorModeManager, isGuestUser, editorMode, isAbleToShowPageAuthors, currentPage, currentUser, isPageTemplateModalShown, isLinkSharingDisabled, templateMenuItemClickHandler, mutateEditorMode]);
 
-  if (currentPathname == null) {
-    return <></>;
-  }
 
-  const notFoundPage: Partial<IPageHasId> = {
-    path: currentPathname,
-  };
+  const pagePath = isNotFound
+    ? currentPathname
+    : currentPage?.path;
 
   return (
     <GrowiSubNavigation
-      page={currentPage ?? notFoundPage}
+      pagePath={pagePath}
+      pageId={currentPage?._id}
       showDrawerToggler={isDrawerMode}
       showTagLabel={isAbleToShowTagLabel}
-      showPageAuthors={isAbleToShowPageAuthors}
       isGuestUser={isGuestUser}
       isDrawerMode={isDrawerMode}
       isCompactMode={isCompactMode}
-      isNotFound={isNotFound}
       tags={isViewMode ? tagsInfoData?.tags : tagsForEditors}
       tagsUpdatedHandler={isViewMode ? tagsUpdatedHandlerForViewMode : tagsUpdatedHandlerForEditMode}
-      controls={ControlComponents}
+      rightComponent={RightComponent}
       additionalClasses={['container-fluid']}
     />
   );

+ 4 - 5
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -144,18 +144,17 @@ export const GrowiNavbar = (): JSX.Element => {
         {appTitle}
       </div>
 
-
       {/* Navbar Right  */}
       <ul className="navbar-nav ml-auto">
         <NavbarRight />
         <Confidential confidential={confidential} />
       </ul>
 
-      { isSearchServiceConfigured && !isDeviceSmallerThanMd && !isSearchPage && (
-        <div className="grw-global-search-container position-absolute">
+      <div className="grw-global-search-container position-absolute">
+        { isSearchServiceConfigured && !isDeviceSmallerThanMd && !isSearchPage && (
           <GlobalSearch />
-        </div>
-      ) }
+        ) }
+      </div>
     </nav>
   );
 

+ 15 - 1
packages/app/src/components/Navbar/GrowiSubNavigation.module.scss

@@ -44,6 +44,21 @@
       }
     }
 
+    .btn-copy {
+      &:not(:hover):not(:active) {
+        background-color: transparent !important;
+      }
+      opacity: 0.5;
+    }
+
+    .btn-edit-tags {
+      opacity: 0.5;
+
+      &.no-tags {
+        opacity: 0.7;
+      }
+    }
+
     .btn-skelton {
       @extend %subnav-buttons-height;
       width: 100%;
@@ -109,7 +124,6 @@
   &:global {
     &:hover {
       .btn-copy,
-      .btn-edit,
       .btn-edit-tags {
         // change button opacity
         opacity: unset;

+ 20 - 45
packages/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -2,43 +2,37 @@ import React from 'react';
 
 import dynamic from 'next/dynamic';
 
-import { IPageHasId } from '~/interfaces/page';
-import { IUser } from '~/interfaces/user';
 import {
   EditorMode, useEditorMode,
 } from '~/stores/ui';
 
 import { TagLabelsSkelton } from '../Page/TagLabels';
 import PagePathNav from '../PagePathNav';
-import { Skelton } from '../Skelton';
 
 import DrawerToggler from './DrawerToggler';
 
-import AuthorInfoStyles from './AuthorInfo.module.scss';
+
 import styles from './GrowiSubNavigation.module.scss';
 
+
 const TagLabels = dynamic(() => import('../Page/TagLabels').then(mod => mod.TagLabels), {
   ssr: false,
-  loading: () => <TagLabelsSkelton />,
-});
-const AuthorInfo = dynamic(() => import('./AuthorInfo'), {
-  ssr: false,
-  loading: () => <Skelton additionalClass={`${AuthorInfoStyles['grw-author-info-skelton']} py-1`} />,
+  loading: TagLabelsSkelton,
 });
 
 
 export type GrowiSubNavigationProps = {
-  page: Partial<IPageHasId>,
+  pagePath?: string,
+  pageId?: string,
+  isNotFound?: boolean,
   showDrawerToggler?: boolean,
   showTagLabel?: boolean,
-  showPageAuthors?: boolean,
   isGuestUser?: boolean,
   isDrawerMode?: boolean,
   isCompactMode?: boolean,
-  isNotFound?: boolean,
   tags?: string[],
   tagsUpdatedHandler?: (newTags: string[]) => Promise<void> | void,
-  controls: React.FunctionComponent,
+  rightComponent: React.FunctionComponent,
   additionalClasses?: string[],
 }
 
@@ -47,11 +41,11 @@ export const GrowiSubNavigation = (props: GrowiSubNavigationProps): JSX.Element
   const { data: editorMode } = useEditorMode();
 
   const {
-    page,
-    showDrawerToggler, showTagLabel, showPageAuthors,
-    isGuestUser, isDrawerMode, isCompactMode, isNotFound,
+    pageId, pagePath,
+    showDrawerToggler, showTagLabel,
+    isGuestUser, isDrawerMode, isCompactMode,
     tags, tagsUpdatedHandler,
-    controls: Controls,
+    rightComponent: RightComponent,
     additionalClasses = [],
   } = props;
 
@@ -59,15 +53,6 @@ export const GrowiSubNavigation = (props: GrowiSubNavigationProps): JSX.Element
   const isEditorMode = !isViewMode;
   const compactModeClasses = isCompactMode ? 'grw-subnav-compact d-print-none' : '';
 
-  const {
-    _id: pageId, path, creator, lastUpdateUser,
-    createdAt, updatedAt,
-  } = page;
-
-  if (path == null) {
-    return <></>;
-  }
-
   return (
     <div className={`grw-subnav ${styles['grw-subnav']} d-flex align-items-center justify-content-between ${additionalClasses.join(' ')}
     ${compactModeClasses}`} >
@@ -81,29 +66,19 @@ export const GrowiSubNavigation = (props: GrowiSubNavigationProps): JSX.Element
         <div className="grw-path-nav-container">
           { (showTagLabel && !isCompactMode) && (
             <div className="grw-taglabels-container">
-              <TagLabels tags={tags} isGuestUser={isGuestUser ?? false} tagsUpdateInvoked={tagsUpdatedHandler} />
+              { tags != null
+                ? <TagLabels tags={tags} isGuestUser={isGuestUser ?? false} tagsUpdateInvoked={tagsUpdatedHandler} />
+                : <TagLabelsSkelton />
+              }
             </div>
           ) }
-          <PagePathNav pageId={pageId} pagePath={path} isSingleLineMode={isEditorMode} isCompactMode={isCompactMode} />
-        </div>
-      </div>
-      {/* Right side. isNotFound for avoid flicker when called ForbiddenPage.tsx */}
-      { !isNotFound && (
-        <div className="d-flex">
-          <Controls />
-          {/* Page Authors */}
-          { (showPageAuthors && !isCompactMode) && (
-            <ul className={`${AuthorInfoStyles['grw-author-info']} text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3`}>
-              <li className="pb-1">
-                <AuthorInfo user={creator as IUser} date={createdAt} locate="subnav" />
-              </li>
-              <li className="mt-1 pt-1 border-top">
-                <AuthorInfo user={lastUpdateUser as IUser} date={updatedAt} mode="update" locate="subnav" />
-              </li>
-            </ul>
+          { pagePath != null && (
+            <PagePathNav pageId={pageId} pagePath={pagePath} isSingleLineMode={isEditorMode} isCompactMode={isCompactMode} />
           ) }
         </div>
-      ) }
+      </div>
+      {/* Right side. */}
+      <RightComponent />
     </div>
   );
 };

+ 11 - 6
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -2,6 +2,7 @@ import React, { useRef } from 'react';
 
 import { UserPicture } from '@growi/ui';
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 import { useRipple } from 'react-use-ripple';
 
 import { toastError } from '~/client/util/apiNotification';
@@ -53,12 +54,16 @@ const PersonalDropdown = () => {
           </div>
 
           <div className="btn-group btn-block mt-2" role="group">
-            <a className="btn btn-sm btn-outline-secondary col" href={`/user/${user.username}`}>
-              <i className="icon-fw icon-home"></i>{ t('personal_dropdown.home') }
-            </a>
-            <a className="btn btn-sm btn-outline-secondary col" href="/me">
-              <i className="icon-fw icon-wrench"></i>{ t('personal_dropdown.settings') }
-            </a>
+            <Link href={`/user/${user.username}`}>
+              <a className="btn btn-sm btn-outline-secondary col">
+                <i className="icon-fw icon-home"></i>{ t('personal_dropdown.home') }
+              </a>
+            </Link>
+            <Link href="/me">
+              <a className="btn btn-sm btn-outline-secondary col">
+                <i className="icon-fw icon-wrench"></i>{ t('personal_dropdown.settings') }
+              </a>
+            </Link>
           </div>
         </div>
 

+ 3 - 2
packages/app/src/components/Page.tsx

@@ -10,7 +10,7 @@ import { HtmlElementNode } from 'rehype-toc';
 
 // import { getOptionsToSave } from '~/client/util/editor';
 import {
-  useIsGuestUser, useCurrentPageTocNode,
+  useIsGuestUser, useCurrentPageTocNode, useShareLinkId,
 } from '~/stores/context';
 import {
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
@@ -200,7 +200,8 @@ export const Page = (props) => {
     tocRef.current = toc;
   }, []);
 
-  const { data: currentPage } = useSWRxCurrentPage();
+  const { data: shareLinkId } = useShareLinkId();
+  const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
   const { data: editorMode } = useEditorMode();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isMobile } = useIsMobile();

+ 7 - 11
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -6,7 +6,7 @@ import dynamic from 'next/dynamic';
 
 // import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import {
-  useCurrentPagePath, useIsSharedUser, useIsEditable, useIsUserPage, usePageUser, useShareLinkId, useIsNotFound,
+  useCurrentPagePath, useIsSharedUser, useIsEditable, useShareLinkId, useIsNotFound,
 } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
@@ -17,22 +17,19 @@ 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';
 
-
-const { isTopPage } = pagePathUtils;
+const { isTopPage, isUsersHomePage } = pagePathUtils;
 
 
 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'), { ssr: false });
+const ContentLinkButtons = dynamic(() => import('../ContentLinkButtons').then(mod => mod.ContentLinkButtons), { ssr: false });
 const NotFoundPage = dynamic(() => import('../NotFoundPage'), { ssr: false });
-const UserInfo = dynamic<UserInfoProps>(() => import('../User/UserInfo').then(mod => mod.UserInfo), { ssr: false });
+const UserInfo = dynamic(() => import('../User/UserInfo').then(mod => mod.UserInfo), { ssr: false });
 
 
 const PageView = React.memo((): JSX.Element => {
@@ -41,19 +38,18 @@ const PageView = React.memo((): JSX.Element => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: shareLinkId } = useShareLinkId();
-  const { data: isUserPage } = useIsUserPage();
-  const { data: pageUser } = usePageUser();
   const { data: isNotFound } = useIsNotFound();
   const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
 
   const isTopPagePath = isTopPage(currentPagePath ?? '');
+  const isUsersHomePagePath = isUsersHomePage(currentPagePath ?? '');
 
   return (
     <div className="d-flex flex-column flex-lg-row">
 
       <div className="flex-grow-1 flex-basis-0 mw-0">
-        { isUserPage && pageUser != null && <UserInfo pageUser={pageUser} />}
+        { isUsersHomePagePath && <UserInfo /> }
         { !isNotFound && <Page /> }
         { isNotFound && <NotFoundPage /> }
       </div>
@@ -98,7 +94,7 @@ const PageView = React.memo((): JSX.Element => {
 
             <div className="d-none d-lg-block">
               <TableOfContents />
-              <ContentLinkButtons />
+              { isUsersHomePagePath && <ContentLinkButtons /> }
             </div>
 
           </div>

+ 0 - 52
packages/app/src/components/Page/ShareLinkAlert.jsx

@@ -1,52 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-
-const ShareLinkAlert = () => {
-  const { t } = useTranslation();
-
-  const shareContent = document.getElementById('is-shared-page');
-  const expiredAt = shareContent.getAttribute('data-share-link-expired-at');
-  const createdAt = shareContent.getAttribute('data-share-link-created-at');
-
-  function generateRatio() {
-    const wholeTime = new Date(expiredAt).getTime() - new Date(createdAt).getTime();
-    const remainingTime = new Date(expiredAt).getTime() - new Date().getTime();
-    return remainingTime / wholeTime;
-  }
-
-  let ratio = 1;
-
-  if (expiredAt !== '') {
-    ratio = generateRatio();
-  }
-
-  function specifyColor() {
-    let color;
-    if (ratio >= 0.75) {
-      color = 'success';
-    }
-    else if (ratio < 0.75 && ratio >= 0.5) {
-      color = 'info';
-    }
-    else if (ratio < 0.5 && ratio >= 0.25) {
-      color = 'warning';
-    }
-    else {
-      color = 'danger';
-    }
-    return color;
-  }
-
-  return (
-    <p className={`alert alert-${specifyColor()} py-3 px-4 d-edit-none`}>
-      <i className="icon-fw icon-link"></i>
-      {(expiredAt === '' ? <span>{t('page_page.notice.no_deadline')}</span>
-      // eslint-disable-next-line react/no-danger
-        : <span dangerouslySetInnerHTML={{ __html: t('page_page.notice.expiration', { expiredAt }) }} />
-      )}
-    </p>
-  );
-};
-
-export default ShareLinkAlert;

+ 52 - 0
packages/app/src/components/Page/ShareLinkAlert.tsx

@@ -0,0 +1,52 @@
+import React, { FC } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+const generateRatio = (expiredAt: Date, createdAt: Date): number => {
+  const wholeTime = new Date(expiredAt).getTime() - new Date(createdAt).getTime();
+  const remainingTime = new Date(expiredAt).getTime() - new Date().getTime();
+  return remainingTime / wholeTime;
+};
+
+const getAlertColor = (ratio: number): string => {
+  let color: string;
+
+  if (ratio >= 0.75) {
+    color = 'success';
+  }
+  else if (ratio < 0.75 && ratio >= 0.5) {
+    color = 'info';
+  }
+  else if (ratio < 0.5 && ratio >= 0.25) {
+    color = 'warning';
+  }
+  else {
+    color = 'danger';
+  }
+  return color;
+};
+
+type Props = {
+  createdAt: Date,
+  expiredAt?: Date,
+}
+
+const ShareLinkAlert: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+  const { expiredAt, createdAt } = props;
+
+  const ratio = expiredAt != null ? generateRatio(expiredAt, createdAt) : 1;
+  const alertColor = getAlertColor(ratio);
+
+  return (
+    <p className={`alert alert-${alertColor} my-3 px-4 d-edit-none`}>
+      <i className="icon-fw icon-link"></i>
+      {(expiredAt === null ? <span>{t('page_page.notice.no_deadline')}</span>
+      // eslint-disable-next-line react/no-danger
+        : <span dangerouslySetInnerHTML={{ __html: t('page_page.notice.expiration', { expiredAt }) }} />
+      )}
+    </p>
+  );
+};
+
+export default ShareLinkAlert;

+ 62 - 65
packages/app/src/components/PageAttachment.tsx

@@ -1,65 +1,49 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React, {
+  useCallback, useMemo, useState,
+} from 'react';
 
-import { useTranslation } from 'next-i18next';
+import { HasObjectId, IAttachment } from '@growi/core';
 
 import { useSWRxAttachments } from '~/stores/attachment';
 import { useEditingMarkdown, useCurrentPageId, useIsGuestUser } from '~/stores/context';
 
-import DeleteAttachmentModal from './PageAttachment/DeleteAttachmentModal';
-import PageAttachmentList from './PageAttachment/PageAttachmentList';
+import { DeleteAttachmentModal } from './PageAttachment/DeleteAttachmentModal';
+import { PageAttachmentList } from './PageAttachment/PageAttachmentList';
 import PaginationWrapper from './PaginationWrapper';
 
 // Utility
-const checkIfFileInUse = (markdown: string, attachment) => {
-  return markdown.match(attachment._id);
-};
-
-// Custom hook that handles processes related to inUseAttachments
-const useInUseAttachments = (attachments) => {
-  const { data: markdown } = useEditingMarkdown();
-  const [inUse, setInUse] = useState<any>({});
-
-  // Update inUse when either of attachments or markdown is updated
-  useEffect(() => {
-    if (markdown == null) {
-      return;
-    }
-
-    const newInUse = {};
-
-    for (const attachment of attachments) {
-      newInUse[attachment._id] = checkIfFileInUse(markdown, attachment);
-    }
-
-    setInUse(newInUse);
-  }, [attachments, markdown]);
-
-  return inUse;
+const checkIfFileInUse = (markdown: string, attachment): boolean => {
+  return markdown.indexOf(attachment._id) >= 0;
 };
 
 const PageAttachment = (): JSX.Element => {
-  const { t } = useTranslation();
-
   // Static SWRs
   const { data: pageId } = useCurrentPageId();
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: markdown } = useEditingMarkdown();
 
   // States
   const [pageNumber, setPageNumber] = useState(1);
-  const [attachmentToDelete, setAttachmentToDelete] = useState<any>(undefined);
+  const [attachmentToDelete, setAttachmentToDelete] = useState<(IAttachment & HasObjectId) | null>(null);
   const [deleting, setDeleting] = useState(false);
   const [deleteError, setDeleteError] = useState('');
 
   // SWRs
   const { data: dataAttachments, remove } = useSWRxAttachments(pageId, pageNumber);
-  const {
-    attachments = [],
-    totalAttachments = 0,
-    limit,
-  } = dataAttachments ?? {};
 
   // Custom hooks
-  const inUseAttachments = useInUseAttachments(attachments);
+  const inUseAttachmentsMap: { [id: string]: boolean } | undefined = useMemo(() => {
+    if (markdown == null || dataAttachments == null) {
+      return undefined;
+    }
+
+    const attachmentEntries = dataAttachments.attachments
+      .map((attachment) => {
+        return [attachment._id, checkIfFileInUse(markdown, attachment)];
+      });
+
+    return Object.fromEntries(attachmentEntries);
+  }, [dataAttachments, markdown]);
 
   // Methods
   const onChangePageHandler = useCallback((newPageNumber: number) => {
@@ -70,7 +54,7 @@ const PageAttachment = (): JSX.Element => {
     setAttachmentToDelete(attachment);
   }, []);
 
-  const onAttachmentDeleteClickedConfirmHandler = useCallback(async(attachment) => {
+  const onAttachmentDeleteClickedConfirmHandler = useCallback(async(attachment: IAttachment & HasObjectId) => {
     setDeleting(true);
 
     try {
@@ -91,22 +75,32 @@ const PageAttachment = (): JSX.Element => {
   }, []);
 
   // Renderers
-  const renderDeleteAttachmentModal = useCallback(() => {
-    if (isGuestUser) {
-      return <></>;
-    }
-
-    if (attachments.length === 0) {
+  const renderPageAttachmentList = useCallback(() => {
+    if (dataAttachments == null || inUseAttachmentsMap == null) {
       return (
-        <div data-testid="page-attachment">
-          {t('No_attachments_yet')}
+        <div className="text-muted text-center">
+          <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
         </div>
       );
     }
 
-    let deleteInUse = null;
-    if (attachmentToDelete != null) {
-      deleteInUse = inUseAttachments[attachmentToDelete._id] || false;
+    return (
+      <PageAttachmentList
+        attachments={dataAttachments.attachments}
+        inUse={inUseAttachmentsMap}
+        onAttachmentDeleteClicked={onAttachmentDeleteClicked}
+        isUserLoggedIn={!isGuestUser}
+      />
+    );
+  }, [dataAttachments, inUseAttachmentsMap, isGuestUser, onAttachmentDeleteClicked]);
+
+  const renderDeleteAttachmentModal = useCallback(() => {
+    if (isGuestUser) {
+      return <></>;
+    }
+
+    if (dataAttachments == null || dataAttachments.attachments.length === 0 || attachmentToDelete == null) {
+      return <></>;
     }
 
     const isOpen = attachmentToDelete != null;
@@ -114,36 +108,39 @@ const PageAttachment = (): JSX.Element => {
     return (
       <DeleteAttachmentModal
         isOpen={isOpen}
-        animation="false"
         toggle={onToggleHandler}
         attachmentToDelete={attachmentToDelete}
-        inUse={deleteInUse}
         deleting={deleting}
         deleteError={deleteError}
         onAttachmentDeleteClickedConfirm={onAttachmentDeleteClickedConfirmHandler}
       />
     );
   // eslint-disable-next-line max-len
-  }, [attachmentToDelete, attachments.length, deleteError, deleting, inUseAttachments, isGuestUser, onAttachmentDeleteClickedConfirmHandler, onToggleHandler, t]);
+  }, [attachmentToDelete, dataAttachments, deleteError, deleting, isGuestUser, onAttachmentDeleteClickedConfirmHandler, onToggleHandler]);
 
-  return (
-    <div data-testid="page-attachment">
-      <PageAttachmentList
-        attachments={attachments}
-        inUse={inUseAttachments}
-        onAttachmentDeleteClicked={onAttachmentDeleteClicked}
-        isUserLoggedIn={!isGuestUser}
-      />
-
-      {renderDeleteAttachmentModal()}
+  const renderPaginationWrapper = useCallback(() => {
+    if (dataAttachments == null || dataAttachments.attachments.length === 0) {
+      return <></>;
+    }
 
+    return (
       <PaginationWrapper
         activePage={pageNumber}
         changePage={onChangePageHandler}
-        totalItemsCount={totalAttachments}
-        pagingLimit={limit}
+        totalItemsCount={dataAttachments.totalAttachments}
+        pagingLimit={dataAttachments.limit}
         align="center"
       />
+    );
+  }, [dataAttachments, onChangePageHandler, pageNumber]);
+
+  return (
+    <div data-testid="page-attachment">
+      {renderPageAttachmentList()}
+
+      {renderDeleteAttachmentModal()}
+
+      {renderPaginationWrapper()}
     </div>
   );
 };

+ 0 - 97
packages/app/src/components/PageAttachment/DeleteAttachmentModal.jsx

@@ -1,97 +0,0 @@
-/* eslint-disable react/prop-types */
-import React from 'react';
-
-import {
-  Button,
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-
-import { UserPicture } from '@growi/ui';
-import Username from '../User/Username';
-
-export default class DeleteAttachmentModal extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this._onDeleteConfirm = this._onDeleteConfirm.bind(this);
-  }
-
-  _onDeleteConfirm() {
-    this.props.onAttachmentDeleteClickedConfirm(this.props.attachmentToDelete);
-  }
-
-  iconNameByFormat(format) {
-    if (format.match(/image\/.+/i)) {
-      return 'icon-picture';
-    }
-
-    return 'icon-doc';
-  }
-
-  renderByFileFormat(attachment) {
-    const content = (attachment.fileFormat.match(/image\/.+/i))
-      ? <img src={attachment.filePathProxied} alt="deleting image" />
-      : '';
-
-
-    return (
-      <div className="attachment-delete-image">
-        <p>
-          <i className={this.iconNameByFormat(attachment.fileFormat)}></i> {attachment.originalName}
-        </p>
-        <p>
-          uploaded by <UserPicture user={attachment.creator} size="sm"></UserPicture> <Username user={attachment.creator}></Username>
-        </p>
-        {content}
-      </div>
-    );
-  }
-
-  render() {
-    const attachment = this.props.attachmentToDelete;
-    if (attachment === null) {
-      return null;
-    }
-
-    const props = Object.assign({}, this.props);
-    delete props.onAttachmentDeleteClickedConfirm;
-    delete props.attachmentToDelete;
-    delete props.inUse;
-    delete props.deleting;
-    delete props.deleteError;
-
-    let deletingIndicator = '';
-    if (this.props.deleting) {
-      deletingIndicator = <div className="speeding-wheel-sm"></div>;
-    }
-    if (this.props.deleteError) {
-      deletingIndicator = <span>{this.props.deleteError}</span>;
-    }
-
-    const renderAttachment = this.renderByFileFormat(attachment);
-
-    return (
-      <Modal {...props} className="attachment-delete-modal" bssize="large" aria-labelledby="contained-modal-title-lg">
-        <ModalHeader tag="h4" toggle={this.props.toggle} className="bg-danger text-light">
-          <span id="contained-modal-title-lg">Delete attachment?</span>
-        </ModalHeader>
-        <ModalBody>
-          {renderAttachment}
-        </ModalBody>
-        <ModalFooter>
-          <div className="mr-3 d-inline-block">
-            {deletingIndicator}
-          </div>
-          <Button
-            color="danger"
-            onClick={this._onDeleteConfirm}
-            disabled={this.props.deleting}
-          >Delete!
-          </Button>
-        </ModalFooter>
-      </Modal>
-    );
-  }
-
-}

+ 98 - 0
packages/app/src/components/PageAttachment/DeleteAttachmentModal.tsx

@@ -0,0 +1,98 @@
+/* eslint-disable react/prop-types */
+import React, { useCallback } from 'react';
+
+import { HasObjectId, IAttachment } from '@growi/core';
+import { UserPicture } from '@growi/ui';
+import {
+  Button,
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import Username from '../User/Username';
+
+
+function iconNameByFormat(format: string): string {
+  if (format.match(/image\/.+/i)) {
+    return 'icon-picture';
+  }
+
+  return 'icon-doc';
+}
+
+
+type Props = {
+  isOpen: boolean,
+  toggle: () => void,
+  attachmentToDelete: IAttachment & HasObjectId | null,
+  deleting: boolean,
+  deleteError: string,
+  onAttachmentDeleteClickedConfirm?: (attachment: IAttachment & HasObjectId) => Promise<void>,
+}
+
+export const DeleteAttachmentModal = (props: Props): JSX.Element => {
+
+  const {
+    isOpen, toggle,
+    attachmentToDelete, deleting, deleteError,
+    onAttachmentDeleteClickedConfirm,
+  } = props;
+
+  const onDeleteConfirm = useCallback(() => {
+    if (attachmentToDelete == null || onAttachmentDeleteClickedConfirm == null) {
+      return;
+    }
+    onAttachmentDeleteClickedConfirm(attachmentToDelete);
+  }, [attachmentToDelete, onAttachmentDeleteClickedConfirm]);
+
+  const renderByFileFormat = useCallback((attachment) => {
+    const content = (attachment.fileFormat.match(/image\/.+/i))
+      // eslint-disable-next-line @next/next/no-img-element
+      ? <img src={attachment.filePathProxied} alt="deleting image" />
+      : '';
+
+
+    return (
+      <div className="attachment-delete-image">
+        <p>
+          <i className={iconNameByFormat(attachment.fileFormat)}></i> {attachment.originalName}
+        </p>
+        <p>
+          uploaded by <UserPicture user={attachment.creator} size="sm"></UserPicture> <Username user={attachment.creator}></Username>
+        </p>
+        {content}
+      </div>
+    );
+  }, []);
+
+  let deletingIndicator = <></>;
+  if (deleting) {
+    deletingIndicator = <div className="speeding-wheel-sm"></div>;
+  }
+  if (deleteError) {
+    deletingIndicator = <span>{deleteError}</span>;
+  }
+
+
+  return (
+    <Modal isOpen={isOpen} className="attachment-delete-modal" size="lg" aria-labelledby="contained-modal-title-lg" fade={false}>
+      <ModalHeader tag="h4" toggle={toggle} className="bg-danger text-light">
+        <span id="contained-modal-title-lg">Delete attachment?</span>
+      </ModalHeader>
+      <ModalBody>
+        {renderByFileFormat(attachmentToDelete)}
+      </ModalBody>
+      <ModalFooter>
+        <div className="mr-3 d-inline-block">
+          {deletingIndicator}
+        </div>
+        <Button
+          color="danger"
+          onClick={onDeleteConfirm}
+          disabled={deleting}
+        >Delete!
+        </Button>
+      </ModalFooter>
+    </Modal>
+  );
+
+};

+ 0 - 45
packages/app/src/components/PageAttachment/PageAttachmentList.jsx

@@ -1,45 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { Attachment } from '@growi/ui';
-
-export default class PageAttachmentList extends React.Component {
-
-  render() {
-    if (this.props.attachments <= 0) {
-      return null;
-    }
-
-    const attachmentList = this.props.attachments.map((attachment, idx) => {
-      return (
-        <Attachment
-          key={`page:attachment:${attachment._id}`}
-          attachment={attachment}
-          inUse={this.props.inUse[attachment._id] || false}
-          onAttachmentDeleteClicked={this.props.onAttachmentDeleteClicked}
-          isUserLoggedIn={this.props.isUserLoggedIn}
-        />
-      );
-    });
-
-    return (
-      <div>
-        <ul className="pl-2">
-          {attachmentList}
-        </ul>
-      </div>
-    );
-  }
-
-}
-
-PageAttachmentList.propTypes = {
-  attachments: PropTypes.arrayOf(PropTypes.object),
-  inUse: PropTypes.objectOf(PropTypes.bool),
-  onAttachmentDeleteClicked: PropTypes.func,
-  isUserLoggedIn: PropTypes.bool,
-};
-PageAttachmentList.defaultProps = {
-  attachments: [],
-  inUse: {},
-};

+ 47 - 0
packages/app/src/components/PageAttachment/PageAttachmentList.tsx

@@ -0,0 +1,47 @@
+import React from 'react';
+
+
+import { HasObjectId, IAttachment } from '@growi/core';
+import { Attachment } from '@growi/ui';
+import { useTranslation } from 'next-i18next';
+
+
+type Props = {
+  attachments: (IAttachment & HasObjectId)[],
+  inUse: { [id: string]: boolean },
+  onAttachmentDeleteClicked?: (attachment: IAttachment & HasObjectId) => void,
+  isUserLoggedIn?: boolean,
+}
+
+export const PageAttachmentList = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    attachments, inUse, onAttachmentDeleteClicked, isUserLoggedIn,
+  } = props;
+
+  if (attachments.length === 0) {
+    return <>{t('No_attachments_yet')}</>;
+  }
+
+  const attachmentList = attachments.map((attachment) => {
+    return (
+      <Attachment
+        key={`page:attachment:${attachment._id}`}
+        attachment={attachment}
+        inUse={inUse[attachment._id] || false}
+        onAttachmentDeleteClicked={onAttachmentDeleteClicked}
+        isUserLoggedIn={isUserLoggedIn}
+      />
+    );
+  });
+
+  return (
+    <div>
+      <ul className="pl-2">
+        {attachmentList}
+      </ul>
+    </div>
+  );
+
+};

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

@@ -230,10 +230,10 @@ class CodeMirrorEditor extends AbstractEditor {
    * @inheritDoc
    */
   forceToFocus() {
-    const editor = this.getCodeMirror();
     // use setInterval with reluctance -- 2018.01.11 Yuki Takei
     const intervalId = setInterval(() => {
-      this.getCodeMirror().focus();
+      const editor = this.getCodeMirror();
+      editor.focus();
       if (editor.hasFocus()) {
         clearInterval(intervalId);
         // refresh

+ 1 - 2
packages/app/src/components/PagePathNav.tsx

@@ -7,8 +7,6 @@ import { useIsNotFound } from '~/stores/context';
 
 import LinkedPagePath from '../models/linked-page-path';
 
-import PagePathHierarchicalLink from './PagePathHierarchicalLink';
-
 const { isTrashPage } = pagePathUtils;
 
 type Props = {
@@ -19,6 +17,7 @@ type Props = {
 }
 
 const CopyDropdown = dynamic(() => import('./Page/CopyDropdown'), { ssr: false });
+const PagePathHierarchicalLink = dynamic(() => import('./PagePathHierarchicalLink'), { ssr: false });
 
 const PagePathNav: FC<Props> = (props: Props) => {
   const {

+ 8 - 7
packages/app/src/components/PasswordResetExecutionForm.jsx → packages/app/src/components/PasswordResetExecutionForm.tsx

@@ -1,6 +1,7 @@
-import React, { useState } from 'react';
+import React, { FC, useState } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiv3Put } from '~/client/util/apiv3-client';
@@ -9,7 +10,7 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:passwordReset');
 
 
-const PasswordResetExecutionForm = (props) => {
+const PasswordResetExecutionForm: FC = () => {
   const { t } = useTranslation();
 
   const [newPassword, setNewPassword] = useState('');
@@ -79,14 +80,14 @@ const PasswordResetExecutionForm = (props) => {
       <div className="form-group">
         <input name="reset-password-btn" className="btn btn-lg btn-primary btn-block" value={t('forgot_password.reset_password')} type="submit" />
       </div>
-      <a href="/login">
-        <i className="icon-login mr-1"></i>{t('forgot_password.sign_in_instead')}
-      </a>
+      <Link href="/login" prefetch={false}>
+        <a>
+          <i className="icon-login mr-1"></i>{t('forgot_password.sign_in_instead')}
+        </a>
+      </Link>
     </form>
   );
 };
 
-PasswordResetExecutionForm.propTypes = {
-};
 
 export default PasswordResetExecutionForm;

+ 13 - 12
packages/app/src/components/PasswordResetRequestForm.jsx → packages/app/src/components/PasswordResetRequestForm.tsx

@@ -1,20 +1,21 @@
-import React, { useState } from 'react';
+import React, { FC, useState, useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiv3Post } from '~/client/util/apiv3-client';
 
 
-const PasswordResetRequestForm = (props) => {
+const PasswordResetRequestForm: FC = () => {
   const { t } = useTranslation();
   const [email, setEmail] = useState('');
 
-  const changeEmail = (inputValue) => {
+  const changeEmail = useCallback((inputValue) => {
     setEmail(inputValue);
-  };
+  }, []);
 
-  const sendPasswordResetRequestMail = async(e) => {
+  const sendPasswordResetRequestMail = useCallback(async(e) => {
     e.preventDefault();
     if (email === '') {
       toastError('err', t('forgot_password.email_is_required'));
@@ -28,10 +29,11 @@ const PasswordResetRequestForm = (props) => {
     catch (err) {
       toastError(err);
     }
-  };
+  }, [t, email]);
 
   return (
     <form onSubmit={sendPasswordResetRequestMail}>
+      <h3>{ t('forgot_password.password_reset_request_desc') }</h3>
       <div className="form-group">
         <div className="input-group">
           <input name="email" placeholder="E-mail Address" className="form-control" type="email" onChange={e => changeEmail(e.target.value)} />
@@ -45,14 +47,13 @@ const PasswordResetRequestForm = (props) => {
           {t('forgot_password.send')}
         </button>
       </div>
-      <a href="/login">
-        <i className="icon-login mr-1"></i>{t('forgot_password.return_to_login')}
-      </a>
+      <Link href='/login' prefetch={false}>
+        <a>
+          <i className="icon-login mr-1" />{t('forgot_password.return_to_login')}
+        </a>
+      </Link>
     </form>
   );
 };
 
-PasswordResetRequestForm.propTypes = {
-};
-
 export default PasswordResetRequestForm;

+ 2 - 5
packages/app/src/components/SavePageControls.tsx

@@ -59,7 +59,7 @@ export const SavePageControls = (props: Props): JSX.Element | null => {
   }, [mutateIsEnabledUnsavedWarning]);
 
 
-  if (isEditable == null || isAclEnabled == null) {
+  if (isEditable == null || isAclEnabled == null || grantData == null) {
     return null;
   }
 
@@ -67,10 +67,7 @@ export const SavePageControls = (props: Props): JSX.Element | null => {
     return null;
   }
 
-  const grant = grantData?.grant || PageGrant.GRANT_PUBLIC;
-  const grantedGroup = grantData?.grantedGroup;
-
-  // const {  pageContainer } = props;
+  const { grant, grantedGroup } = grantData;
 
   const isRootPage = isTopPage(currentPagePath ?? '');
   const labelSubmitButton = pageId == null ? t('Create') : t('Update');

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

@@ -170,7 +170,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
   }, [onDeletedHandler, openDeleteModal]);
 
-  const ControlComponents = useCallback(() => {
+  const RightComponent = useCallback(() => {
     if (page == null) {
       return <></>;
     }
@@ -202,8 +202,9 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     <div key={page._id} data-testid="search-result-content" className="search-result-content grw-page-path-text-muted-container d-flex flex-column">
       <div className="grw-subnav-append-shadow-container">
         <GrowiSubNavigation
-          page={page}
-          controls={ControlComponents}
+          pagePath={page.path}
+          pageId={page._id}
+          rightComponent={RightComponent}
           isCompactMode
           additionalClasses={['px-4']}
         />

+ 5 - 9
packages/app/src/components/ShareLink/ShareLinkForm.tsx

@@ -1,7 +1,9 @@
 import React, { FC, useState, useCallback } from 'react';
 
 import { isInteger } from 'core-js/fn/number';
-import { format, parse } from 'date-fns';
+import {
+  format, parse, addDays, set,
+} from 'date-fns';
 import { useTranslation } from 'next-i18next';
 
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
@@ -56,8 +58,6 @@ export const ShareLinkForm: FC<Props> = (props: Props) => {
   }, []);
 
   const generateExpired = useCallback(() => {
-    let expiredAt;
-
     if (expirationType === ExpirationType.UNLIMITED) {
       return null;
     }
@@ -66,16 +66,12 @@ export const ShareLinkForm: FC<Props> = (props: Props) => {
       if (!isInteger(Number(numberOfDays))) {
         throw new Error(t('share_links.Invalid_Number_of_Date'));
       }
-      const date = new Date();
-      date.setDate(date.getDate() + Number(numberOfDays));
-      expiredAt = date;
+      return addDays(new Date(), numberOfDays);
     }
 
     if (expirationType === ExpirationType.CUSTOM) {
-      expiredAt = parse(`${customExpirationDate}T${customExpirationTime}`, "yyyy-MM-dd'T'HH:mm", new Date());
+      return set(customExpirationDate, { hours: customExpirationTime.getHours(), minutes: customExpirationTime.getMinutes() });
     }
-
-    return expiredAt;
   }, [t, customExpirationTime, customExpirationDate, expirationType, numberOfDays]);
 
   const closeForm = useCallback(() => {

+ 2 - 2
packages/app/src/components/Skelton.tsx

@@ -11,8 +11,8 @@ export const Skelton = (props: SkeltonProps): JSX.Element => {
   } = props;
 
   return (
-    <div className={`${additionalClass}`}>
-      <div className={`grw-skelton h-100 w-100 ${roundedPill ? 'rounded-pill' : ''}`}></div>
+    <div className={`${additionalClass ?? ''}`}>
+      <div className={`grw-skelton h-100 w-100 ${roundedPill ?? ''}`}></div>
     </div>
   );
 };

+ 22 - 0
packages/app/src/components/User/SeenUserInfo.module.scss

@@ -0,0 +1,22 @@
+@use '~/styles/bootstrap/init' as bs;
+@use '~/styles/mixins';
+
+.grw-seen-user-info :global {
+  .btn.btn-seen-user {
+    $color-seen-user: #549c79;
+
+    @include bs.button-outline-variant($color-seen-user, $color-seen-user, rgba(lighten($color-seen-user, 10%), 0.15), rgba(lighten($color-seen-user, 10%), 0.5));
+    @include mixins.button-outline-svg-icon-variant($color-seen-user, $color-seen-user);
+
+    &:not(:disabled):not(.disabled):active,
+    &:not(:disabled):not(.disabled).active {
+      color: $color-seen-user;
+      svg {
+        fill: $color-seen-user;
+      }
+    }
+    &:not(:disabled):not(.disabled):not(:hover) {
+      background-color: transparent;
+    }
+  }
+}

+ 5 - 1
packages/app/src/components/User/SeenUserInfo.tsx

@@ -8,6 +8,10 @@ import { IUser } from '~/interfaces/user';
 
 import UserPictureList from './UserPictureList';
 
+
+import styles from './SeenUserInfo.module.scss';
+
+
 interface Props {
   seenUsers: IUser[],
   sumOfSeenUsers?: number,
@@ -23,7 +27,7 @@ const SeenUserInfo: FC<Props> = (props: Props) => {
   const togglePopover = () => setIsPopoverOpen(!isPopoverOpen);
 
   return (
-    <div className="grw-seen-user-info">
+    <div className={`grw-seen-user-info ${styles['grw-seen-user-info']}`}>
       <button type="button" id="btn-seen-user" className="shadow-none btn btn-seen-user border-0">
         <span className="mr-1 footstamp-icon">
           <FootstampIcon />

+ 4 - 9
packages/app/src/components/User/UserInfo.tsx

@@ -2,20 +2,15 @@ import React from 'react';
 
 import { UserPicture } from '@growi/ui';
 
-import { IUserHasId } from '~/interfaces/user';
+import { usePageUser } from '~/stores/context';
 
 import styles from './UserInfo.module.scss';
 
-export type UserInfoProps = {
-  pageUser: IUserHasId,
-}
+export const UserInfo = (): JSX.Element => {
 
-export const UserInfo = (props: UserInfoProps): JSX.Element => {
+  const { data: pageUser } = usePageUser();
 
-  const { pageUser } = props;
-
-  // Do not display when the user does not exist
-  if (pageUser == null) {
+  if (pageUser == null || pageUser.status === 4) {
     return <></>;
   }
 

+ 2 - 4
packages/app/src/interfaces/attachment.ts

@@ -1,10 +1,8 @@
-import type { IAttachment } from '@growi/core';
+import type { HasObjectId, IAttachment } from '@growi/core';
 
 import type { PaginateResult } from './mongoose-utils';
 
 
 export type IResAttachmentList = {
-  data: {
-    paginateResult: PaginateResult<IAttachment>
-  }
+  paginateResult: PaginateResult<IAttachment & HasObjectId>
 };

+ 7 - 0
packages/app/src/interfaces/errors/forgot-password.ts

@@ -0,0 +1,7 @@
+export const forgotPasswordErrorCode = {
+  PASSWORD_RESET_IS_UNAVAILABLE: 'password-reset-is-unavailable',
+  TOKEN_NOT_FOUND: 'token-not-found',
+  PASSWORD_RESET_ORDER_IS_NOT_APPROPRIATE: 'password-reset-order-is-not-appropriate',
+} as const;
+
+export type forgotPasswordErrorCode = typeof forgotPasswordErrorCode[keyof typeof forgotPasswordErrorCode]

+ 11 - 0
packages/app/src/interfaces/share-link.ts

@@ -1,4 +1,15 @@
+import { IPageHasId, HasObjectId } from '@growi/core';
+
 // Todo: specify more detailed Type
 export type IResShareLinkList = {
   shareLinksResult: any[],
 };
+
+export type IShareLink = {
+  relatedPage: IPageHasId,
+  createdAt: Date,
+  expiredAt?: Date,
+  description: string,
+};
+
+export type IShareLinkHasId = IShareLink & HasObjectId;

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

@@ -237,16 +237,16 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   }
 
   const pageId = pageWithMeta?.data._id;
-  const pagePath = pageWithMeta?.data.path ?? props.currentPathname;
+  const pagePath = pageWithMeta?.data.path ?? (!_isPermalink(props.currentPathname) ? props.currentPathname : undefined);
 
   useCurrentPageId(pageId);
   useSWRxCurrentPage(undefined, pageWithMeta?.data); // store initial data
-  useIsUserPage(isUserPage(pagePath));
+  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(_isTrashPage(pagePath));
+  useIsTrashPage(pagePath != null && _isTrashPage(pagePath));
 
   const { data: grantData } = useSWRxIsGrantNormalized(pageId);
   const { mutate: mutateSelectedGrant } = useSelectedGrant();
@@ -399,6 +399,11 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
   const pageWithMeta: IPageToShowRevisionWithMeta = await pageService.findPageAndMetaDataByViewer(pageId, currentPathname, user, true); // includeEmpty = true, isSharedPage = false
   const page = pageWithMeta?.data as unknown as PageDocument;
 
+  // add user to seen users
+  if (page != null && user != null) {
+    await page.seen(user);
+  }
+
   // populate & check if the revision is latest
   if (page != null) {
     page.initLatestRevisionField(revisionId);

+ 2 - 1
packages/app/src/pages/admin/[[...path]].page.tsx

@@ -312,7 +312,8 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
  * @param namespacesRequired
  */
 async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
-  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
+  // preload all languages because of language lists in user setting
+  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired, true);
   props._nextI18Next = nextI18NextConfig._nextI18Next;
 }
 

+ 90 - 0
packages/app/src/pages/forgot-password-errors.page.tsx

@@ -0,0 +1,90 @@
+import React from 'react';
+
+import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
+import { useTranslation } from 'next-i18next';
+import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
+import Link from 'next/link';
+
+import { forgotPasswordErrorCode } from '~/interfaces/errors/forgot-password';
+
+import {
+  CommonProps, getNextI18NextConfig, getServerSideCommonProps,
+} from './utils/commons';
+
+type Props = CommonProps & {
+  errorCode?: forgotPasswordErrorCode
+};
+
+const ForgotPasswordErrorsPage: NextPage<Props> = (props: Props) => {
+  const { t } = useTranslation();
+  const { errorCode } = props;
+
+  return (
+    <div id="main" className="main">
+      <div id="content-main" className="content-main container-lg">
+        <div className="container">
+          <div className="row justify-content-md-center">
+            <div className="col-md-6 mt-5">
+              <div className="text-center">
+                <h1><i className="icon-lock-open large"/></h1>
+                <h2 className="text-center">{ t('forgot_password.reset_password') }</h2>
+
+                { errorCode == null && (
+                  <h3 className="text-muted">errorCode Unknown</h3>
+                )}
+
+                { errorCode === forgotPasswordErrorCode.PASSWORD_RESET_IS_UNAVAILABLE && (
+                  <h3 className="text-muted">{ t('forgot_password.feature_is_unavailable') }</h3>
+                )}
+
+                { (errorCode === forgotPasswordErrorCode.PASSWORD_RESET_ORDER_IS_NOT_APPROPRIATE || errorCode === forgotPasswordErrorCode.TOKEN_NOT_FOUND) && (
+                  <div>
+                    <div className="alert alert-warning mb-3">
+                      <h2>{ t('forgot_password.incorrect_token_or_expired_url') }</h2>
+                    </div>
+                    <Link href="/forgot-password" prefetch={false}>
+                      <a className="link-switch">
+                        <i className="icon-key"></i> { t('forgot_password.forgot_password') }
+                      </a>
+                    </Link>
+                  </div>
+                ) }
+
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
+  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
+  props._nextI18Next = nextI18NextConfig._nextI18Next;
+}
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const result = await getServerSideCommonProps(context);
+
+  // check for presence
+  // see: https://github.com/vercel/next.js/issues/19271#issuecomment-730006862
+  if (!('props' in result)) {
+    throw new Error('invalid getSSP result');
+  }
+
+  const props: Props = result.props as Props;
+
+  const errorCode = context.query.errorCode;
+  if (typeof errorCode === 'string') {
+    props.errorCode = errorCode as forgotPasswordErrorCode;
+  }
+
+  await injectNextI18NextConfigurations(context, props, ['translation']);
+
+  return {
+    props,
+  };
+};
+
+export default ForgotPasswordErrorsPage;

+ 60 - 0
packages/app/src/pages/forgot-password.page.tsx

@@ -0,0 +1,60 @@
+import React from 'react';
+
+import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
+import { useTranslation } from 'next-i18next';
+import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
+import dynamic from 'next/dynamic';
+
+import {
+  CommonProps, getNextI18NextConfig, getServerSideCommonProps,
+} from './utils/commons';
+
+const PasswordResetRequestForm = dynamic(() => import('~/components/PasswordResetRequestForm'), { ssr: false });
+
+const ForgotPasswordPage: NextPage = () => {
+  const { t } = useTranslation();
+
+  return (
+    <div id="main" className="main">
+      <div id="content-main" className="content-main container-lg">
+        <div className="container">
+          <div className="row justify-content-md-center">
+            <div className="col-md-6 mt-5">
+              <div className="text-center">
+                <h1><i className="icon-lock large"></i></h1>
+                <h1 className="text-center">{ t('forgot_password.forgot_password') }</h1>
+                <PasswordResetRequestForm />
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+// eslint-disable-next-line max-len
+async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: CommonProps, namespacesRequired?: string[] | undefined): Promise<void> {
+  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
+  props._nextI18Next = nextI18NextConfig._nextI18Next;
+}
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const result = await getServerSideCommonProps(context);
+
+  // check for presence
+  // see: https://github.com/vercel/next.js/issues/19271#issuecomment-730006862
+  if (!('props' in result)) {
+    throw new Error('invalid getSSP result');
+  }
+
+  const props: CommonProps = result.props as CommonProps;
+
+  await injectNextI18NextConfigurations(context, props, ['translation']);
+
+  return {
+    props,
+  };
+};
+
+export default ForgotPasswordPage;

+ 26 - 6
packages/app/src/pages/login.page.tsx

@@ -4,9 +4,10 @@ import React from 'react';
 import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
-import dynamic from 'next/dynamic';
+import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 
 import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
+import { LoginForm } from '~/components/LoginForm';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 
 import {
@@ -16,11 +17,9 @@ import {
 
 
 import {
-  CommonProps, getServerSideCommonProps, useCustomTitle,
+  CommonProps, getServerSideCommonProps, useCustomTitle, getNextI18NextConfig,
 } from './utils/commons';
 
-const LoginForm = dynamic(() => import('~/components/LoginForm'), { ssr: false });
-
 type Props = CommonProps & {
 
   pageWithMetaStr: string,
@@ -41,12 +40,32 @@ const LoginPage: NextPage<Props> = (props: Props) => {
 
   return (
     <NoLoginLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
-      <LoginForm objOfIsExternalAuthEnableds={props.enabledStrategies} isLocalStrategySetup={true} isLdapStrategySetup={true}
-        isRegistrationEnabled={true} registrationWhiteList={props.registrationWhiteList} isPasswordResetEnabled={true} />
+      <LoginForm
+        // Todo: These props should be set properly. https://redmine.weseek.co.jp/issues/104847
+        objOfIsExternalAuthEnableds={props.enabledStrategies}
+        isLocalStrategySetup={true}
+        isLdapStrategySetup={true}
+        isEmailAuthenticationEnabled={false}
+        isRegistrationEnabled={true}
+        registrationWhiteList={props.registrationWhiteList}
+        isPasswordResetEnabled={true}
+        isMailerSetup={false}
+      />
     </NoLoginLayout>
   );
 };
 
+/**
+ * for Server Side Translations
+ * @param context
+ * @param props
+ * @param namespacesRequired
+ */
+async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
+  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
+  props._nextI18Next = nextI18NextConfig._nextI18Next;
+}
+
 function injectEnabledStrategies(context: GetServerSidePropsContext, props: Props): void {
   const req: CrowiRequest = context.req as CrowiRequest;
   const { crowi } = req;
@@ -92,6 +111,7 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
 
   injectServerConfigurations(context, props);
   injectEnabledStrategies(context, props);
+  await injectNextI18NextConfigurations(context, props, ['translation']);
 
   return {
     props,

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

@@ -169,7 +169,8 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
 //  * @param namespacesRequired
 //  */
 async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
-  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
+  // preload all languages because of language lists in user setting
+  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired, true);
   props._nextI18Next = nextI18NextConfig._nextI18Next;
 }
 
@@ -195,7 +196,7 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
 
   await injectUserUISettings(context, props);
   await injectServerConfigurations(context, props);
-  await injectNextI18NextConfigurations(context, props, ['translation']);
+  await injectNextI18NextConfigurations(context, props, ['translation', 'admin']);
 
   return {
     props,

+ 72 - 0
packages/app/src/pages/reset-password.page.tsx

@@ -0,0 +1,72 @@
+import React from 'react';
+
+import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
+import { useTranslation } from 'next-i18next';
+import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
+import dynamic from 'next/dynamic';
+
+import {
+  CommonProps, getNextI18NextConfig, getServerSideCommonProps,
+} from './utils/commons';
+
+
+type Props = CommonProps & {
+  email: string
+};
+
+const PasswordResetExecutionForm = dynamic(() => import('~/components/PasswordResetExecutionForm'), { ssr: false });
+
+const ForgotPasswordPage: NextPage<Props> = (props: Props) => {
+  const { t } = useTranslation();
+
+  return (
+    <div id="main" className="main">
+      <div id="content-main" className="content-main container-lg">
+        <div className="container">
+          <div className="row justify-content-md-center">
+            <div className="col-md-6 mt-5">
+              <div className="text-center">
+                <h1><i className="icon-lock-open large"></i></h1>
+                <h2 className="text-center">{ t('forgot_password.reset_password') }</h2>
+                <h5>{ props.email }</h5>
+                <p className="mt-4">{ t('forgot_password.password_reset_excecution_desc') }</p>
+                <PasswordResetExecutionForm />
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+// eslint-disable-next-line max-len
+async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
+  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
+  props._nextI18Next = nextI18NextConfig._nextI18Next;
+}
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const result = await getServerSideCommonProps(context);
+
+  // check for presence
+  // see: https://github.com/vercel/next.js/issues/19271#issuecomment-730006862
+  if (!('props' in result)) {
+    throw new Error('invalid getSSP result');
+  }
+
+  const props: Props = result.props as Props;
+
+  const email = context.query.email;
+  if (typeof email === 'string') {
+    props.email = email;
+  }
+
+  await injectNextI18NextConfigurations(context, props, ['translation']);
+
+  return {
+    props,
+  };
+};
+
+export default ForgotPasswordPage;

+ 169 - 0
packages/app/src/pages/share/[[...path]].page.tsx

@@ -0,0 +1,169 @@
+import React from 'react';
+
+import { IUser, IUserHasId } from '@growi/core';
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
+import dynamic from 'next/dynamic';
+
+import { ShareLinkLayout } from '~/components/Layout/ShareLinkLayout';
+import GrowiContextualSubNavigation from '~/components/Navbar/GrowiContextualSubNavigation';
+import { Page } from '~/components/Page';
+import { CrowiRequest } from '~/interfaces/crowi-request';
+import { RendererConfig } from '~/interfaces/services/renderer';
+import { IShareLinkHasId } from '~/interfaces/share-link';
+import {
+  useCurrentUser, useCurrentPagePath, useCurrentPathname, useCurrentPageId, useRendererConfig,
+  useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault,
+} from '~/stores/context';
+
+import {
+  CommonProps, getServerSideCommonProps, useCustomTitle, getNextI18NextConfig,
+} from '../utils/commons';
+
+const ShareLinkAlert = dynamic(() => import('~/components/Page/ShareLinkAlert'), { ssr: false });
+const ForbiddenPage = dynamic(() => import('~/components/ForbiddenPage'), { ssr: false });
+
+type Props = CommonProps & {
+  shareLink?: IShareLinkHasId,
+  isExpired: boolean,
+  currentUser: IUser,
+  disableLinkSharing: boolean,
+  isSearchServiceConfigured: boolean,
+  isSearchServiceReachable: boolean,
+  isSearchScopeChildrenAsDefault: boolean,
+  rendererConfig: RendererConfig,
+};
+
+const SharedPage: NextPage<Props> = (props: Props) => {
+  useShareLinkId(props.shareLink?._id);
+  useCurrentPageId(props.shareLink?.relatedPage._id);
+  useCurrentPagePath(props.shareLink?.relatedPage.path);
+  useCurrentUser(props.currentUser);
+  useCurrentPathname(props.currentPathname);
+  useRendererConfig(props.rendererConfig);
+  useIsSearchServiceConfigured(props.isSearchServiceConfigured);
+  useIsSearchServiceReachable(props.isSearchServiceReachable);
+  useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
+
+  const isNotFound = props.shareLink == null || props.shareLink.relatedPage == null || props.shareLink.relatedPage.isEmpty;
+  const isShowSharedPage = !props.disableLinkSharing && !isNotFound && !props.isExpired;
+
+  return (
+    <ShareLinkLayout title={useCustomTitle(props, 'GROWI')} expandContainer={props.isContainerFluid}>
+      <div className="h-100 d-flex flex-column justify-content-between">
+        <header className="py-0 position-relative">
+          {isShowSharedPage && <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />}
+        </header>
+
+        <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
+
+        <div className="flex-grow-1">
+          <div id="content-main" className="content-main grw-container-convertible">
+            { props.disableLinkSharing && (
+              <div className="mt-4">
+                <ForbiddenPage isLinkSharingDisabled={props.disableLinkSharing} />
+              </div>
+            )}
+
+            { (isNotFound && !props.disableLinkSharing) && (
+              <div className="container-lg">
+                <h2 className="text-muted mt-4">
+                  <i className="icon-ban" aria-hidden="true" />
+                  <span> Page is not found</span>
+                </h2>
+              </div>
+            )}
+
+            { (props.isExpired && !props.disableLinkSharing) && (
+              <div className="container-lg">
+                <h2 className="text-muted mt-4">
+                  <i className="icon-ban" aria-hidden="true" />
+                  <span> Page is expired</span>
+                </h2>
+              </div>
+            )}
+
+            {(isShowSharedPage && props.shareLink != null) && (
+              <>
+                <ShareLinkAlert expiredAt={props.shareLink.expiredAt} createdAt={props.shareLink.createdAt} />
+                <Page />
+              </>
+            )}
+          </div>
+        </div>
+      </div>
+    </ShareLinkLayout>
+  );
+};
+
+function injectServerConfigurations(context: GetServerSidePropsContext, props: Props): void {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+
+  props.disableLinkSharing = crowi.configManager.getConfig('crowi', 'security:disableLinkSharing');
+
+  props.isSearchServiceConfigured = crowi.searchService.isConfigured;
+  props.isSearchServiceReachable = crowi.searchService.isReachable;
+  props.isSearchScopeChildrenAsDefault = crowi.configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
+
+  props.rendererConfig = {
+    isEnabledLinebreaks: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
+    isEnabledLinebreaksInComments: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
+    adminPreferredIndentSize: crowi.configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
+    isIndentSizeForced: crowi.configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
+
+    plantumlUri: process.env.PLANTUML_URI ?? null,
+    blockdiagUri: process.env.BLOCKDIAG_URI ?? null,
+
+    // XSS Options
+    isEnabledXssPrevention: crowi.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
+    attrWhiteList: crowi.xssService.getAttrWhiteList(),
+    tagWhiteList: crowi.xssService.getTagWhiteList(),
+    highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
+  };
+}
+
+async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
+  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
+  props._nextI18Next = nextI18NextConfig._nextI18Next;
+}
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const { user, crowi } = req;
+  const result = await getServerSideCommonProps(context);
+
+  if (!('props' in result)) {
+    throw new Error('invalid getSSP result');
+  }
+  const props: Props = result.props as Props;
+
+  if (user != null) {
+    props.currentUser = user.toObject();
+  }
+
+  const { linkId } = req.params;
+  try {
+    const ShareLinkModel = crowi.model('ShareLink');
+    const shareLink = await ShareLinkModel.findOne({ _id: linkId }).populate('relatedPage');
+    if (shareLink != null) {
+      props.isExpired = shareLink.isExpired();
+      props.shareLink = shareLink.toObject();
+    }
+  }
+  catch (err) {
+    //
+  }
+
+  injectServerConfigurations(context, props);
+  // await injectUserUISettings(context, props);
+  await injectNextI18NextConfigurations(context, props);
+
+  return {
+    props,
+  };
+};
+
+export default SharedPage;

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

@@ -5,10 +5,9 @@ import {
 } from '@growi/core';
 import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
 import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
 import Head from 'next/head';
 
-import TagCloudBox from '~/components/TagCloudBox';
-import TagList from '~/components/TagList';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { IDataTagCount } from '~/interfaces/tag';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
@@ -36,6 +35,9 @@ type Props = CommonProps & {
   userUISettings?: IUserUISettings
 };
 
+const TagList = dynamic(() => import('~/components/TagList'), { ssr: false });
+const TagCloudBox = dynamic(() => import('~/components/TagCloudBox'), { ssr: false });
+
 const TagPage: NextPage<CommonProps> = (props: Props) => {
   const [activePage, setActivePage] = useState<number>(1);
   const [offset, setOffset] = useState<number>(0);
@@ -85,6 +87,7 @@ const TagPage: NextPage<CommonProps> = (props: Props) => {
               </div>
             )
           }
+          <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
         </div>
       </BasicLayout>
     </>

+ 7 - 5
packages/app/src/pages/utils/commons.ts

@@ -1,4 +1,4 @@
-import { DevidedPagePath, Lang } from '@growi/core';
+import { DevidedPagePath, Lang, AllLang } from '@growi/core';
 import { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import { SSRConfig, UserConfig } from 'next-i18next';
 
@@ -44,7 +44,7 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
     namespacesRequired: ['translation'],
     currentPathname,
     appTitle: appService.getAppTitle(),
-    siteUrl: appService.getSiteUrl(),
+    siteUrl: configManager.getConfig('crowi', 'app:siteUrl'), // DON'T USE appService.getSiteUrl()
     confidential: appService.getAppConfidential() || '',
     theme: configManager.getConfig('crowi', 'customize:theme'),
     customTitleTemplate: customizeService.customTitleTemplate,
@@ -61,8 +61,10 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
 export const getNextI18NextConfig = async(
     // 'serverSideTranslations' method should be given from Next.js Page
     //  because importing it in this file causes https://github.com/isaachinman/next-i18next/issues/1545
-    serverSideTranslations: (initialLocale: string, namespacesRequired?: string[] | undefined, configOverride?: UserConfig | null) => Promise<SSRConfig>,
-    context: GetServerSidePropsContext, namespacesRequired?: string[] | undefined,
+    serverSideTranslations: (
+      initialLocale: string, namespacesRequired?: string[] | undefined, configOverride?: UserConfig | null, extraLocales?: string[] | false
+    ) => Promise<SSRConfig>,
+    context: GetServerSidePropsContext, namespacesRequired?: string[] | undefined, preloadAllLang = false,
 ): Promise<SSRConfig> => {
 
   const req: CrowiRequest = context.req as CrowiRequest;
@@ -74,7 +76,7 @@ export const getNextI18NextConfig = async(
     ?? configManager.getConfig('crowi', 'app:globalLang') as Lang
     ?? Lang.en_US;
 
-  return serverSideTranslations(locale, namespacesRequired ?? ['translation'], nextI18NextConfig);
+  return serverSideTranslations(locale, namespacesRequired ?? ['translation'], nextI18NextConfig, preloadAllLang ? AllLang : false);
 };
 
 /**

+ 15 - 2
packages/app/src/server/middlewares/inject-reset-order-by-token-middleware.ts

@@ -1,24 +1,37 @@
 import { NextFunction, Request, Response } from 'express';
 import createError from 'http-errors';
 
+import { forgotPasswordErrorCode } from '~/interfaces/errors/forgot-password';
+import loggerFactory from '~/utils/logger';
+
 import PasswordResetOrder, { IPasswordResetOrder } from '../models/password-reset-order';
 
+const logger = loggerFactory('growi:routes:forgot-password');
+
 export type ReqWithPasswordResetOrder = Request & {
   passwordResetOrder: IPasswordResetOrder,
 };
 
+// eslint-disable-next-line import/no-anonymous-default-export
 export default async(req: ReqWithPasswordResetOrder, res: Response, next: NextFunction): Promise<void> => {
   const token = req.params.token || req.body.token;
 
   if (token == null) {
-    return next(createError(400, 'Token not found', { code: 'token-not-found' }));
+    logger.error('Token not found');
+    return next(createError(400, 'Token not found', { code: forgotPasswordErrorCode.TOKEN_NOT_FOUND }));
   }
 
   const passwordResetOrder = await PasswordResetOrder.findOne({ token });
 
   // check if the token is valid
   if (passwordResetOrder == null || passwordResetOrder.isExpired() || passwordResetOrder.isRevoked) {
-    return next(createError(400, 'passwordResetOrder is null or expired or revoked', { code: 'password-reset-order-is-not-appropriate' }));
+    const message = 'passwordResetOrder is null or expired or revoked';
+    logger.error(message);
+    return next(createError(
+      400,
+      'passwordResetOrder is null or expired or revoked',
+      { code: forgotPasswordErrorCode.PASSWORD_RESET_ORDER_IS_NOT_APPROPRIATE },
+    ));
   }
 
   req.passwordResetOrder = passwordResetOrder;

+ 9 - 7
packages/app/src/server/middlewares/login-form-validator.ts

@@ -48,18 +48,18 @@ export const loginRules = () => {
   return [
     body('loginForm.username')
       .matches(/^[\da-zA-Z\-_.+@]+$/)
-      .withMessage('Username or E-mail has invalid characters')
+      .withMessage('message.Username or E-mail has invalid characters')
       .not()
       .isEmpty()
-      .withMessage('Username field is required'),
+      .withMessage('message.Username field is required'),
     body('loginForm.password')
       .matches(/^[\x20-\x7F]*$/)
-      .withMessage('Password has invalid character')
+      .withMessage('message.Password has invalid character')
       .isLength({ min: 6 })
-      .withMessage('Password minimum character should be more than 6 characters')
+      .withMessage('message.Password minimum character should be more than 6 characters')
       .not()
       .isEmpty()
-      .withMessage('Password field is required'),
+      .withMessage('message.Password field is required'),
   ];
 };
 
@@ -77,8 +77,10 @@ export const loginValidation = (req, res, next) => {
   const extractedErrors: string[] = [];
   errors.array().map(err => extractedErrors.push(err.msg));
 
-  req.flash('errorMessages', extractedErrors);
-  Object.assign(form, { isValid: false });
+  Object.assign(form, {
+    isValid: false,
+    errors: extractedErrors,
+  });
   req.form = form;
 
   return next();

+ 8 - 8
packages/app/src/server/middlewares/register-form-validator.ts

@@ -6,24 +6,24 @@ export const registerRules = () => {
   return [
     body('registerForm.username')
       .matches(/^[\da-zA-Z\-_.]+$/)
-      .withMessage('Username has invalid characters')
+      .withMessage('message.Username has invalid characters')
       .not()
       .isEmpty()
-      .withMessage('Username field is required'),
-    body('registerForm.name').not().isEmpty().withMessage('Name field is required'),
+      .withMessage('message.Username field is required'),
+    body('registerForm.name').not().isEmpty().withMessage('message.Name field is required'),
     body('registerForm.email')
       .isEmail()
-      .withMessage('Email format is invalid.')
+      .withMessage('message.Email format is invalid')
       .exists()
-      .withMessage('Email field is required.'),
+      .withMessage('message.Email field is required'),
     body('registerForm.password')
       .matches(/^[\x20-\x7F]*$/)
-      .withMessage('Password has invalid character')
+      .withMessage('message.Password has invalid character')
       .isLength({ min: PASSOWRD_MINIMUM_NUMBER })
-      .withMessage('Password minimum character should be more than 8 characters')
+      .withMessage('message.Password minimum character should be more than 8 characters')
       .not()
       .isEmpty()
-      .withMessage('Password field is required'),
+      .withMessage('message.Password field is required'),
     body('registerForm[app:globalLang]'),
   ];
 };

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

@@ -1,6 +1,9 @@
 import loggerFactory from '~/utils/logger';
 
+import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import injectUserRegistrationOrderByTokenMiddleware from '../../middlewares/inject-user-registration-order-by-token-middleware';
+import * as loginFormValidator from '../../middlewares/login-form-validator';
+import * as registerFormValidator from '../../middlewares/register-form-validator';
 
 import g2gTransfer from './g2g-transfer';
 import pageListing from './page-listing';
@@ -14,7 +17,7 @@ const router = express.Router();
 const routerForAdmin = express.Router();
 const routerForAuth = express.Router();
 
-module.exports = (crowi) => {
+module.exports = (crowi, app, isInstalled) => {
 
   // add custom functions to express response
   require('./response')(express, crowi);
@@ -40,8 +43,24 @@ module.exports = (crowi) => {
   routerForAdmin.use('/g2g-transfer', g2gTransfer(crowi));
 
   // auth
+  const applicationInstalled = require('../../middlewares/application-installed')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
+  const login = require('../login')(crowi, app);
+  const loginPassport = require('../login-passport')(crowi, app);
+
+  routerForAuth.post('/login', applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation,
+    addActivity, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.cannotLoginErrorHadnler, loginPassport.loginFailure);
+
   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));
+    return [router, routerForAdmin, routerForAuth];
+  }
 
   router.use('/in-app-notification', require('./in-app-notification')(crowi));
 

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

@@ -0,0 +1,76 @@
+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';
+import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+import { registerRules } from '../../middlewares/register-form-validator';
+import { InstallerService, FailedToCreateAdminUserError } from '../../service/installer';
+
+import { ApiV3Response } from './interfaces/apiv3-response';
+
+
+const logger = loggerFactory('growi:routes:apiv3:installer');
+
+
+type FormRequest = Request & { form: any, logIn: any };
+
+module.exports = (crowi: Crowi): Router => {
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const activityEvent = crowi.event('activity');
+
+  const router = express.Router();
+
+  // eslint-disable-next-line max-len
+  router.post('/', registerRules(), apiV3FormValidator, addActivity, async(req: FormRequest, res: ApiV3Response) => {
+    const appService = crowi.appService;
+    if (appService == null) {
+      return res.apiv3Err(new ErrorV3('GROWI cannot be installed due to an internal error', 'app_service_not_setup'), 500);
+    }
+    const registerForm = req.body.registerForm || {};
+
+    const name = registerForm.name;
+    const username = registerForm.username;
+    const email = registerForm.email;
+    const password = registerForm.password;
+    const language = registerForm['app:globalLang'] || 'en_US';
+
+    const installerService = new InstallerService(crowi);
+
+    let adminUser;
+    try {
+      adminUser = await installerService.install({
+        name,
+        username,
+        email,
+        password,
+      }, language);
+    }
+    catch (err) {
+      if (err instanceof FailedToCreateAdminUserError) {
+        return res.apiv3Err(new ErrorV3(err.message, 'failed_to_create_admin_user'));
+      }
+      return res.apiv3Err(new ErrorV3(err, 'failed_to_install'));
+    }
+
+    await appService.setupAfterInstall();
+
+    const parameters = { action: SupportedAction.ACTION_USER_REGISTRATION_SUCCESS };
+    activityEvent.emit('update', res.locals.activity._id, parameters);
+
+    // login with passport
+    req.logIn(adminUser, (err) => {
+      if (err != null) {
+        return res.apiv3Err(new ErrorV3(err, 'failed_to_login_after_install'));
+      }
+
+      return res.apiv3({ message: 'Installation completed (Logged in as an admin user)' });
+    });
+  });
+
+  return router;
+};

+ 41 - 14
packages/app/src/server/routes/forgot-password.ts

@@ -1,12 +1,12 @@
 import {
-  NextFunction, Request, RequestHandler, Response,
+  NextFunction, Request, Response,
 } from 'express';
-
 import createError from 'http-errors';
 
+import { forgotPasswordErrorCode } from '~/interfaces/errors/forgot-password';
 import loggerFactory from '~/utils/logger';
 
-import { ReqWithPasswordResetOrder } from '../middlewares/inject-reset-order-by-token-middleware';
+import { IPasswordResetOrder } from '../models/password-reset-order';
 
 const logger = loggerFactory('growi:routes:forgot-password');
 
@@ -25,7 +25,7 @@ export const checkForgotPasswordEnabledMiddlewareFactory = (crowi: any, forApi =
       logger.error(message);
 
       const statusCode = forApi ? 405 : 404;
-      return next(createError(statusCode, message, { code: 'password-reset-is-unavailable' }));
+      return next(createError(statusCode, message, { code: forgotPasswordErrorCode.PASSWORD_RESET_IS_UNAVAILABLE }));
     }
 
     next();
@@ -33,19 +33,46 @@ export const checkForgotPasswordEnabledMiddlewareFactory = (crowi: any, forApi =
 
 };
 
-export const forgotPassword = (req: Request, res: Response): void => {
-  return res.render('forgot-password');
+type Crowi = {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  nextApp: any,
+}
+
+type CrowiReq = Request & {
+  crowi: Crowi,
+}
+
+export const renderForgotPassword = (crowi: Crowi) => {
+  return (req: CrowiReq, res: Response, next: NextFunction): void => {
+    const { nextApp } = crowi;
+    req.crowi = crowi;
+    nextApp.render(req, res, '/forgot-password');
+    return;
+  };
 };
 
-export const resetPassword = (req: ReqWithPasswordResetOrder, res: Response): void => {
-  const { passwordResetOrder } = req;
-  return res.render('reset-password', { email: passwordResetOrder.email });
+export const renderResetPassword = (crowi: Crowi) => {
+  return (req: CrowiReq & { passwordResetOrder: IPasswordResetOrder }, res: Response, next: NextFunction): void => {
+    const { nextApp } = crowi;
+    req.crowi = crowi;
+    nextApp.render(req, res, '/reset-password', { email: req.passwordResetOrder.email });
+    return;
+  };
 };
 
 // middleware to handle error
-export const handleErrosMiddleware = (error: Error & { code: string }, req: Request, res: Response, next: NextFunction): Promise<RequestHandler> | void => {
-  if (error != null) {
-    return res.render('forgot-password/error', { key: error.code });
-  }
-  next(error);
+export const handleErrorsMiddleware = (crowi: Crowi) => {
+  return (error: Error & { code: string, statusCode: number }, req: CrowiReq, res: Response, next: NextFunction): void => {
+    if (error != null) {
+      const { nextApp } = crowi;
+
+      req.crowi = crowi;
+      res.status(error.statusCode);
+
+      nextApp.render(req, res, '/forgot-password-errors', { errorCode: error.code });
+      return;
+    }
+
+    next();
+  };
 };

+ 7 - 10
packages/app/src/server/routes/index.js

@@ -61,9 +61,9 @@ module.exports = function(crowi, app) {
 
   /* eslint-disable max-len, comma-spacing, no-multi-spaces */
 
-  const [apiV3Router, apiV3AdminRouter, apiV3AuthRouter] = require('./apiv3')(crowi);
+  const [apiV3Router, apiV3AdminRouter, apiV3AuthRouter] = require('./apiv3')(crowi, app, isInstalled);
 
-  app.use('/api-docs', require('./apiv3/docs')(crowi));
+  app.use('/api-docs', require('./apiv3/docs')(crowi, app));
 
   // Rate limiter
   app.use(rateLimiter);
@@ -82,9 +82,7 @@ module.exports = function(crowi, app) {
   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.post('/register'                , applicationInstalled, registerFormValidator.registerRules(), registerFormValidator.registerValidation, csrfProtection, addActivity, login.register);
   app.get('/register'                 , applicationInstalled, login.preLogin, login.register);
 
   app.get('/admin/*'                    , applicationInstalled, loginRequiredStrictly , adminRequired , next.delegateToNext);
@@ -93,9 +91,7 @@ module.exports = function(crowi, app) {
 
   // installer
   if (!isInstalled) {
-    const installer = require('./installer')(crowi);
     app.get('/installer'              , applicationNotInstalled, next.delegateToNext);
-    app.post('/installer'             , applicationNotInstalled , registerFormValidator.registerRules(), registerFormValidator.registerValidation, csrfProtection, addActivity, installer.install);
     return;
   }
 
@@ -207,6 +203,7 @@ module.exports = function(crowi, app) {
   // app.get('/tags'                     , loginRequired, tag.showPage);
   app.get('/tags', loginRequired, next.delegateToNext);
 
+  app.get('/me'                                 , loginRequiredStrictly, injectUserUISettings, next.delegateToNext);
   app.get('/me/*'                                 , loginRequiredStrictly, injectUserUISettings, next.delegateToNext);
   // external-accounts
   // my in-app-notifications
@@ -235,9 +232,9 @@ module.exports = function(crowi, app) {
 
   app.use('/forgot-password', express.Router()
     .use(forgotPassword.checkForgotPasswordEnabledMiddlewareFactory(crowi))
-    .get('/', forgotPassword.forgotPassword)
-    .get('/:token', injectResetOrderByTokenMiddleware, forgotPassword.resetPassword)
-    .use(forgotPassword.handleErrosMiddleware));
+    .get('/', forgotPassword.renderForgotPassword(crowi))
+    .get('/:token', injectResetOrderByTokenMiddleware, forgotPassword.renderResetPassword(crowi))
+    .use(forgotPassword.handleErrorsMiddleware(crowi)));
 
   app.get('/_private-legacy-pages', next.delegateToNext);
   app.use('/user-activation', express.Router()
@@ -245,7 +242,7 @@ module.exports = function(crowi, app) {
     .use(userActivation.tokenErrorHandlerMiddeware));
   app.post('/user-activation/register', applicationInstalled, csrfProtection, userActivation.registerRules(), userActivation.validateRegisterForm, userActivation.registerAction(crowi));
 
-  app.get('/share/:linkId', page.showSharedPage);
+  app.get('/share/:linkId', next.delegateToNext);
 
   app.use('/ogp', express.Router().get('/:pageId([0-9a-z]{0,})', loginRequired, ogp.pageIdRequired, ogp.ogpValidator, ogp.renderOgp));
 

+ 0 - 70
packages/app/src/server/routes/installer.js

@@ -1,70 +0,0 @@
-import { SupportedAction } from '~/interfaces/activity';
-import loggerFactory from '~/utils/logger';
-
-import { InstallerService, FailedToCreateAdminUserError } from '../service/installer';
-
-const logger = loggerFactory('growi:routes:installer');
-
-module.exports = function(crowi) {
-
-  const actions = {};
-
-  const activityEvent = crowi.event('activity');
-
-  actions.index = function(req, res) {
-    return res.render('installer');
-  };
-
-  actions.install = async function(req, res, next) {
-    const registerForm = req.body.registerForm || {};
-
-    if (!req.form.isValid) {
-      return res.render('installer');
-    }
-
-    const name = registerForm.name;
-    const username = registerForm.username;
-    const email = registerForm.email;
-    const password = registerForm.password;
-    const language = registerForm['app:globalLang'] || 'en_US';
-
-    const installerService = new InstallerService(crowi);
-
-    let adminUser;
-    try {
-      adminUser = await installerService.install({
-        name,
-        username,
-        email,
-        password,
-      }, language);
-    }
-    catch (err) {
-      if (err instanceof FailedToCreateAdminUserError) {
-        req.form.errors.push(req.t('message.failed_to_create_admin_user', { errMessage: err.message }));
-      }
-      return res.render('installer');
-    }
-
-    const appService = crowi.appService;
-    appService.setupAfterInstall();
-
-    // login with passport
-    req.logIn(adminUser, (err) => {
-      if (err) {
-        req.flash('successMessage', req.t('message.complete_to_install1'));
-        req.session.redirectTo = '/';
-        return res.redirect('/login');
-      }
-
-      req.flash('successMessage', req.t('message.complete_to_install2'));
-
-      const parameters = { action: SupportedAction.ACTION_USER_REGISTRATION_SUCCESS };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-
-      return res.redirect('/');
-    });
-  };
-
-  return actions;
-};

+ 113 - 90
packages/app/src/server/routes/login-passport.js

@@ -2,6 +2,8 @@ import { SupportedAction } from '~/interfaces/activity';
 import { NullUsernameToBeRegisteredError } from '~/server/models/errors';
 import loggerFactory from '~/utils/logger';
 
+import ErrorV3 from '../models/vo/error-apiv3';
+
 /* eslint-disable no-use-before-define */
 
 module.exports = function(crowi, app) {
@@ -15,6 +17,74 @@ module.exports = function(crowi, app) {
 
   const ApiResponse = require('../util/apiResponse');
 
+  const promisifiedPassportAuthentication = (strategyName, req, res) => {
+    return new Promise((resolve, reject) => {
+      passport.authenticate(strategyName, (err, response, info) => {
+        if (res.headersSent) { // dirty hack -- 2017.09.25
+          return; //              cz: somehow passport.authenticate called twice when ECONNREFUSED error occurred
+        }
+
+        logger.debug(`--- authenticate with ${strategyName} strategy ---`);
+
+        if (err) {
+          logger.error(`'${strategyName}' passport authentication error: `, err);
+          reject(err);
+        }
+
+        logger.debug('response', response);
+        logger.debug('info', info);
+
+        // authentication failure
+        if (!response) {
+          reject(response);
+        }
+
+        resolve(response);
+      })(req, res);
+    });
+  };
+
+  const getOrCreateUser = async(req, res, userInfo, providerId) => {
+    // get option
+    const isSameUsernameTreatedAsIdenticalUser = crowi.passportService.isSameUsernameTreatedAsIdenticalUser(providerId);
+    const isSameEmailTreatedAsIdenticalUser = crowi.passportService.isSameEmailTreatedAsIdenticalUser(providerId);
+
+    try {
+      // find or register(create) user
+      const externalAccount = await ExternalAccount.findOrRegister(
+        providerId,
+        userInfo.id,
+        userInfo.username,
+        userInfo.name,
+        userInfo.email,
+        isSameUsernameTreatedAsIdenticalUser,
+        isSameEmailTreatedAsIdenticalUser,
+      );
+      return externalAccount;
+    }
+    catch (err) {
+      /* eslint-disable no-else-return */
+      if (err instanceof NullUsernameToBeRegisteredError) {
+        logger.error(err.message);
+        throw Error(err.message);
+      }
+      else if (err.name === 'DuplicatedUsernameException') {
+        if (isSameEmailTreatedAsIdenticalUser || isSameUsernameTreatedAsIdenticalUser) {
+          // associate to existing user
+          debug(`ExternalAccount '${userInfo.username}' will be created and bound to the exisiting User account`);
+          return ExternalAccount.associate(providerId, userInfo.id, err.user);
+        }
+        logger.error('provider-DuplicatedUsernameException', providerId);
+        throw Error(`provider-DuplicatedUsernameException: ${providerId}`);
+      }
+      else if (err.name === 'UserUpperLimitException') {
+        logger.error(err.message);
+        throw Error(err.message);
+      }
+      /* eslint-enable no-else-return */
+    }
+  };
+
   /**
    * success handler
    * @param {*} req
@@ -44,7 +114,7 @@ module.exports = function(crowi, app) {
     };
     await crowi.activityService.createActivity(parameters);
 
-    return res.safeRedirect(redirectTo);
+    return res.apiv3({ redirectTo });
   };
 
   /**
@@ -61,13 +131,23 @@ module.exports = function(crowi, app) {
     return res.redirect('/login');
   };
 
+  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');
+    return next(err);
+  };
+
   /**
    * middleware for login failure
    * @param {*} req
    * @param {*} res
    */
-  const loginFailure = (req, res) => {
-    return loginFailureHandler(req, res, req.t('message.sign_in_failure'));
+  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);
   };
 
   /**
@@ -95,13 +175,11 @@ module.exports = function(crowi, app) {
   const loginWithLdap = async(req, res, next) => {
     if (!passportService.isLdapStrategySetup) {
       debug('LdapStrategy has not been set up');
-      return next();
+      return res.apiv3Err('message.strategy_has_not_been_set_up.LdapStrategy', 405);
     }
 
     if (!req.form.isValid) {
-      debug('invalid form');
-      return res.render('login', {
-      });
+      return next(req.form.errors);
     }
 
     const providerId = 'ldap';
@@ -113,12 +191,12 @@ module.exports = function(crowi, app) {
     }
     catch (err) {
       debug(err.message);
-      return next();
+      return next(err);
     }
 
     // check groups for LDAP
     if (!isValidLdapUserByGroupFilter(ldapAccountInfo)) {
-      return next();
+      return next(ErrorV3('message.ldap_user_not_valid', 400));
     }
 
     /*
@@ -141,16 +219,27 @@ module.exports = function(crowi, app) {
       email: mailToBeRegistered,
     };
 
-    const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
-    if (!externalAccount) {
-      return next();
+    let externalAccount;
+    try {
+      externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
+    }
+    catch (error) {
+      return next(error);
+    }
+
+    // just in case the returned value is null or undefined
+    if (externalAccount == null) {
+      return next(Error('message.external_account_not_exist'));
     }
 
     const user = await externalAccount.getPopulatedUser();
 
     // login
     await req.logIn(user, (err) => {
-      if (err) { debug(err.message); return next() }
+      if (err) {
+        debug(err.message);
+        return next(err);
+      }
 
       return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_LDAP);
     });
@@ -221,13 +310,11 @@ module.exports = function(crowi, app) {
   const loginWithLocal = (req, res, next) => {
     if (!passportService.isLocalStrategySetup) {
       debug('LocalStrategy has not been set up');
-      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'LocalStrategy' }));
-      return next();
+      return res.apiv3Err('message.strategy_has_not_been_set_up.LocalStrategy', 405);
     }
 
     if (!req.form.isValid) {
-      return res.render('login', {
-      });
+      return next(req.form.errors);
     }
 
     passport.authenticate('local', (err, user, info) => {
@@ -237,12 +324,16 @@ module.exports = function(crowi, app) {
 
       if (err) { // DB Error
         logger.error('Database Server Error: ', err);
-        req.flash('warningMessage', req.t('message.database_error'));
-        return next(); // pass and the flash message is displayed when all of authentications are failed.
+        return next(err);
+      }
+      if (!user) {
+        return next();
       }
-      if (!user) { return next() }
       req.logIn(user, (err) => {
-        if (err) { debug(err.message); return next() }
+        if (err) {
+          debug(err.message);
+          return next(err);
+        }
 
         return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_LOCAL);
       });
@@ -560,76 +651,8 @@ module.exports = function(crowi, app) {
     });
   };
 
-  const promisifiedPassportAuthentication = (strategyName, req, res) => {
-    return new Promise((resolve, reject) => {
-      passport.authenticate(strategyName, (err, response, info) => {
-        if (res.headersSent) { // dirty hack -- 2017.09.25
-          return; //              cz: somehow passport.authenticate called twice when ECONNREFUSED error occurred
-        }
-
-        logger.debug(`--- authenticate with ${strategyName} strategy ---`);
-
-        if (err) {
-          logger.error(`'${strategyName}' passport authentication error: `, err);
-          reject(err);
-        }
-
-        logger.debug('response', response);
-        logger.debug('info', info);
-
-        // authentication failure
-        if (!response) {
-          reject(response);
-        }
-
-        resolve(response);
-      })(req, res);
-    });
-  };
-
-  const getOrCreateUser = async(req, res, userInfo, providerId) => {
-    // get option
-    const isSameUsernameTreatedAsIdenticalUser = crowi.passportService.isSameUsernameTreatedAsIdenticalUser(providerId);
-    const isSameEmailTreatedAsIdenticalUser = crowi.passportService.isSameEmailTreatedAsIdenticalUser(providerId);
-
-    try {
-      // find or register(create) user
-      const externalAccount = await ExternalAccount.findOrRegister(
-        providerId,
-        userInfo.id,
-        userInfo.username,
-        userInfo.name,
-        userInfo.email,
-        isSameUsernameTreatedAsIdenticalUser,
-        isSameEmailTreatedAsIdenticalUser,
-      );
-      return externalAccount;
-    }
-    catch (err) {
-      /* eslint-disable no-else-return */
-      if (err instanceof NullUsernameToBeRegisteredError) {
-        req.flash('warningMessage', req.t(`message.${err.message}`));
-        return;
-      }
-      else if (err.name === 'DuplicatedUsernameException') {
-        if (isSameEmailTreatedAsIdenticalUser || isSameUsernameTreatedAsIdenticalUser) {
-          // associate to existing user
-          debug(`ExternalAccount '${userInfo.username}' will be created and bound to the exisiting User account`);
-          return ExternalAccount.associate(providerId, userInfo.id, err.user);
-        }
-
-        req.flash('provider-DuplicatedUsernameException', providerId);
-        return;
-      }
-      else if (err.name === 'UserUpperLimitException') {
-        req.flash('warningMessage', req.t('message.maximum_number_of_users'));
-        return;
-      }
-      /* eslint-enable no-else-return */
-    }
-  };
-
   return {
+    cannotLoginErrorHadnler,
     loginFailure,
     loginWithLdap,
     testLdapCredentials,

+ 78 - 94
packages/app/src/server/routes/login.js

@@ -19,34 +19,57 @@ module.exports = function(crowi, app) {
     req.login(userData, (err) => {
       if (err) {
         logger.debug(err);
-        // I created a flash message in case the user information that processing was successful is not stored in the session.
-        req.flash('successMessage', req.t('message.successfully_created', { username: userData.username }));
       }
       else {
         // update lastLoginAt
-        userData.updateLastLoginAt(new Date(), (err, userData) => {
+        userData.updateLastLoginAt(new Date(), (err) => {
           if (err) {
             logger.error(`updateLastLoginAt dumps error: ${err}`);
           }
         });
       }
-      // RegisterFormValidator.registerRule had code to guarantee that there was a password,
-      // but login.register did not. so I wrote this code.
-      if (!userData.password) {
-        return res.redirect('/me#password');
-      }
 
-      const { redirectTo } = req.session;
+
+      // userData.password cann't be empty but, prepare redirect because password property in User Model is optional
+      // https://github.com/weseek/growi/pull/6670
+      const redirectTo = userData.password ? req.session.redirectTo : '/me#password';
+
       // remove session.redirectTo
       delete req.session.redirectTo;
 
       const parameters = { action: SupportedAction.ACTION_USER_REGISTRATION_SUCCESS };
       activityEvent.emit('update', res.locals.activity._id, parameters);
 
-      return res.safeRedirect(redirectTo);
+      return res.apiv3({ redirectTo });
     });
   };
 
+  async function sendEmailToAllAdmins(userData) {
+    // send mails to all admin users (derived from crowi) -- 2020.06.18 Yuki Takei
+    const admins = await User.findAdmins();
+
+    const appTitle = appService.getAppTitle();
+
+    const promises = admins.map((admin) => {
+      return mailService.send({
+        to: admin.email,
+        subject: `[${appTitle}:admin] A New User Created and Waiting for Activation`,
+        template: path.join(crowi.localeDir, 'en_US/admin/userWaitingActivation.txt'),
+        vars: {
+          createdUser: userData,
+          admin,
+          url: appService.getSiteUrl(),
+          appTitle,
+        },
+      });
+    });
+
+    const results = await Promise.allSettled(promises);
+    results
+      .filter(result => result.status === 'rejected')
+      .forEach(result => logger.error(result.reason));
+  }
+
   actions.error = function(req, res) {
     const reason = req.params.reason;
 
@@ -83,107 +106,68 @@ module.exports = function(crowi, app) {
     next();
   };
 
-  actions.login = function(req, res) {
-    if (req.form) {
-      debug(req.form.errors);
-    }
-
-    return res.render('login', {});
-  };
-
   actions.register = function(req, res) {
     if (req.user != null) {
-      return res.redirect('/');
+      return res.apiv3Err('user_already_logged_in', 403);
     }
 
     // config で closed ならさよなら
     if (configManager.getConfig('crowi', 'security:registrationMode') === aclService.labels.SECURITY_REGISTRATION_MODE_CLOSED) {
-      return res.redirect('/');
+      return res.apiv3Err('registration_closed', 403);
     }
 
-    if (req.method === 'POST' && req.form.isValid) {
-      const registerForm = req.form.registerForm || {};
-
-      const name = registerForm.name;
-      const username = registerForm.username;
-      const email = registerForm.email;
-      const password = registerForm.password;
-
-      // email と username の unique チェックする
-      User.isRegisterable(email, username, (isRegisterable, errOn) => {
-        let isError = false;
-        if (!User.isEmailValid(email)) {
-          isError = true;
-          req.flash('registerWarningMessage', req.t('message.email_address_could_not_be_used'));
-        }
-        if (!isRegisterable) {
-          if (!errOn.username) {
-            isError = true;
-            req.flash('registerWarningMessage', req.t('message.user_id_is_not_available'));
-          }
-          if (!errOn.email) {
-            isError = true;
-            req.flash('registerWarningMessage', req.t('message.email_address_is_already_registered'));
-          }
+    if (!req.form.isValid) {
+      const errors = req.form.errors;
+      return res.apiv3Err(errors, 400);
+    }
+
+    const registerForm = req.form.registerForm || {};
+
+    const name = registerForm.name;
+    const username = registerForm.username;
+    const email = registerForm.email;
+    const password = registerForm.password;
+
+    // email と username の unique チェックする
+    User.isRegisterable(email, username, (isRegisterable, errOn) => {
+      const errors = [];
+      if (!User.isEmailValid(email)) {
+        errors.push('email_address_could_not_be_used');
+      }
+      if (!isRegisterable) {
+        if (!errOn.username) {
+          errors.push('user_id_is_not_available');
         }
-        if (isError) {
-          debug('isError user register error', errOn);
-          return res.redirect('/register');
+        if (!errOn.email) {
+          errors.push('email_address_is_already_registered');
         }
+      }
+      if (errors.length > 0) {
+        debug('isError user register error', errOn);
+        return res.apiv3Err(errors, 400);
+      }
 
-        User.createUserByEmailAndPassword(name, username, email, password, undefined, async(err, userData) => {
-          if (err) {
-            if (err.name === 'UserUpperLimitException') {
-              req.flash('registerWarningMessage', req.t('message.can_not_register_maximum_number_of_users'));
-            }
-            else {
-              req.flash('registerWarningMessage', req.t('message.failed_to_register'));
-            }
-            return res.redirect('/register');
+      User.createUserByEmailAndPassword(name, username, email, password, undefined, async(err, userData) => {
+        if (err) {
+          const errors = [];
+          if (err.name === 'UserUpperLimitException') {
+            errors.push('can_not_register_maximum_number_of_users');
           }
-
-          if (configManager.getConfig('crowi', 'security:registrationMode') !== aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
-            // send mail asynchronous
-            sendEmailToAllAdmins(userData);
+          else {
+            errors.push('failed_to_register');
           }
+          return res.apiv3Err(errors, 405);
+        }
 
+        if (configManager.getConfig('crowi', 'security:registrationMode') !== aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
+          // send mail asynchronous
+          sendEmailToAllAdmins(userData);
+        }
 
-          return registerSuccessHandler(req, res, userData);
-        });
-      });
-    }
-    else { // method GET of form is not valid
-      debug('session is', req.session);
-      const isRegistering = true;
-      return res.render('login', { isRegistering });
-    }
-  };
-
-  async function sendEmailToAllAdmins(userData) {
-    // send mails to all admin users (derived from crowi) -- 2020.06.18 Yuki Takei
-    const admins = await User.findAdmins();
-
-    const appTitle = appService.getAppTitle();
-
-    const promises = admins.map((admin) => {
-      return mailService.send({
-        to: admin.email,
-        subject: `[${appTitle}:admin] A New User Created and Waiting for Activation`,
-        template: path.join(crowi.localeDir, 'en_US/admin/userWaitingActivation.txt'),
-        vars: {
-          createdUser: userData,
-          admin,
-          url: appService.getSiteUrl(),
-          appTitle,
-        },
+        return registerSuccessHandler(req, res, userData);
       });
     });
-
-    const results = await Promise.allSettled(promises);
-    results
-      .filter(result => result.status === 'rejected')
-      .forEach(result => logger.error(result.reason));
-  }
+  };
 
   actions.invited = async function(req, res) {
     if (!req.user) {

+ 3 - 1
packages/app/src/server/routes/next.ts

@@ -12,7 +12,7 @@ type CrowiReq = Request & {
 }
 
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-export default (crowi: Crowi) => {
+const delegator = (crowi: Crowi) => {
 
   const { nextApp } = crowi;
   const handle = nextApp.getRequestHandler();
@@ -27,3 +27,5 @@ export default (crowi: Crowi) => {
   };
 
 };
+
+export default delegator;

+ 2 - 2
packages/app/src/server/service/installer.ts

@@ -109,7 +109,7 @@ export class InstallerService {
     return configManager.updateConfigsInTheSameNamespace('crowi', initialConfig, true);
   }
 
-  async install(firstAdminUserToSave: IUser, globalLang: Lang, options?: AutoInstallOptions): Promise<IUser> {
+  async install(firstAdminUserToSave: Pick<IUser, 'name' | 'username' | 'email' | 'password'>, globalLang: Lang, options?: AutoInstallOptions): Promise<IUser> {
     await this.initDB(globalLang, options);
 
     // TODO typescriptize models/user.js and remove eslint-disable-next-line
@@ -144,7 +144,7 @@ export class InstallerService {
     const rootRevision = await Revision.findOne({ path: '/' });
     rootPage.creator = adminUser._id;
     rootPage.lastUpdateUser = adminUser._id;
-    rootRevision.creator = adminUser._id;
+    rootRevision.author = adminUser._id;
     await Promise.all([rootPage.save(), rootRevision.save()]);
 
     // create initial pages

+ 13 - 9
packages/app/src/stores/attachment.tsx

@@ -1,11 +1,13 @@
 import { useCallback } from 'react';
 
 import {
+  HasObjectId,
   IAttachment, Nullable, SWRResponseWithUtils, withUtils,
 } from '@growi/core';
 import useSWR from 'swr';
 
-import { apiGet, apiPost } from '~/client/util/apiv1-client';
+import { apiPost } from '~/client/util/apiv1-client';
+import { apiv3Get } from '~/client/util/apiv3-client';
 import { IResAttachmentList } from '~/interfaces/attachment';
 
 type Util = {
@@ -13,7 +15,7 @@ type Util = {
 };
 
 type IDataAttachmentList = {
-  attachments: IAttachment[]
+  attachments: (IAttachment & HasObjectId)[]
   totalAttachments: number
   limit: number
 };
@@ -21,17 +23,19 @@ type IDataAttachmentList = {
 export const useSWRxAttachments = (pageId?: Nullable<string>, pageNumber?: number): SWRResponseWithUtils<Util, IDataAttachmentList, Error> => {
   const shouldFetch = pageId != null && pageNumber != null;
 
-  const fetcher = useCallback(async(endpoint) => {
-    const res = await apiGet<IResAttachmentList>(endpoint, { pageId, pageNumber });
+  const fetcher = useCallback(async(endpoint, pageId, pageNumber) => {
+    const res = await apiv3Get<IResAttachmentList>(endpoint, { pageId, pageNumber });
+    const resAttachmentList = res.data;
+    const { paginateResult } = resAttachmentList;
     return {
-      attachments: res.data.paginateResult.docs,
-      totalAttachments: res.data.paginateResult.totalDocs,
-      limit: res.data.paginateResult.limit,
+      attachments: paginateResult.docs,
+      totalAttachments: paginateResult.totalDocs,
+      limit: paginateResult.limit,
     };
-  }, [pageId, pageNumber]);
+  }, []);
 
   const swrResponse = useSWR(
-    shouldFetch ? ['/attachments/list', pageId, pageNumber] : null,
+    shouldFetch ? ['/attachment/list', pageId, pageNumber] : null,
     fetcher,
   );
 

+ 2 - 2
packages/app/src/stores/context.tsx

@@ -55,8 +55,8 @@ export const useCurrentPagePath = (initialData?: Nullable<string>): SWRResponse<
   return useStaticSWR<Nullable<string>, Error>('currentPagePath', initialData);
 };
 
-export const useCurrentPathname = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
-  return useStaticSWR<Nullable<string>, Error>('currentPathname', initialData);
+export const useCurrentPathname = (initialData?: string): SWRResponse<string, Error> => {
+  return useStaticSWR('currentPathname', initialData);
 };
 
 export const useCurrentPageId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {

+ 12 - 12
packages/app/src/stores/ui.tsx

@@ -1,7 +1,7 @@
 import { RefObject } from 'react';
 
 import {
-  isClient, isServer, pagePathUtils, Nullable,
+  isClient, isServer, pagePathUtils, Nullable, PageGrant,
 } from '@growi/core';
 import { withUtils, SWRResponseWithUtils } from '@growi/core/src/utils/with-utils';
 import { Breakpoint, addBreakpointListener } from '@growi/ui';
@@ -21,13 +21,13 @@ import { UpdateDescCountData } from '~/interfaces/websocket';
 import loggerFactory from '~/utils/logger';
 
 import {
-  useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage, useIsGuestUser,
+  useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsGuestUser,
   useIsSharedUser, useIsIdenticalPath, useCurrentUser, useIsNotFound, useShareLinkId,
 } from './context';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { useStaticSWR } from './use-static-swr';
 
-const { isTrashTopPage } = pagePathUtils;
+const { isTrashTopPage, isUsersTopPage } = pagePathUtils;
 
 const logger = loggerFactory('growi:stores:ui');
 
@@ -367,7 +367,7 @@ export const useSidebarResizeDisabled = (isDisabled?: boolean): SWRResponse<bool
 };
 
 export const useSelectedGrant = (initialData?: Nullable<IPageGrantData>): SWRResponse<Nullable<IPageGrantData>, Error> => {
-  return useStaticSWR<Nullable<IPageGrantData>, Error>('selectedGrant', initialData);
+  return useStaticSWR<Nullable<IPageGrantData>, Error>('selectedGrant', initialData, { fallbackData: { grant: PageGrant.GRANT_PUBLIC } });
 };
 
 export const useGlobalSearchFormRef = (initialData?: RefObject<IFocusable>): SWRResponse<RefObject<IFocusable>, Error> => {
@@ -424,22 +424,21 @@ export const useIsAbleToShowPageManagement = (): SWRResponse<boolean, Error> =>
 export const useIsAbleToShowTagLabel = (): SWRResponse<boolean, Error> => {
   const key = 'isAbleToShowTagLabel';
   const { data: pageId } = useCurrentPageId();
-  const { data: isUserPage } = useIsUserPage();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isIdenticalPath } = useIsIdenticalPath();
   const { data: isNotFound } = useIsNotFound();
   const { data: editorMode } = useEditorMode();
   const { data: shareLinkId } = useShareLinkId();
 
-  const includesUndefined = [isUserPage, currentPagePath, isIdenticalPath, isNotFound, editorMode].some(v => v === undefined);
+  const includesUndefined = [currentPagePath, isIdenticalPath, isNotFound, editorMode].some(v => v === undefined);
 
   const isViewMode = editorMode === EditorMode.View;
 
   return useSWRImmutable(
-    includesUndefined ? null : [key, editorMode, pageId],
+    includesUndefined ? null : [key, pageId, currentPagePath, isIdenticalPath, isNotFound, editorMode, shareLinkId],
     // "/trash" page does not exist on page collection and unable to add tags
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    () => !isUserPage && !isTrashTopPage(currentPagePath!) && shareLinkId == null && !isIdenticalPath && !(isViewMode && isNotFound),
+    () => !isUsersTopPage(currentPagePath!) && !isTrashTopPage(currentPagePath!) && shareLinkId == null && !isIdenticalPath && !(isViewMode && isNotFound),
   );
 };
 
@@ -459,14 +458,15 @@ export const useIsAbleToShowPageEditorModeManager = (): SWRResponse<boolean, Err
 export const useIsAbleToShowPageAuthors = (): SWRResponse<boolean, Error> => {
   const key = 'isAbleToShowPageAuthors';
   const { data: pageId } = useCurrentPageId();
-  const { data: isUserPage } = useIsUserPage();
+  const { data: pagePath } = useCurrentPagePath();
   const { data: isNotFound } = useIsNotFound();
 
-  const includesUndefined = [pageId, isUserPage, isNotFound].some(v => v === undefined);
+  const includesUndefined = [pageId, pagePath, isNotFound].some(v => v === undefined);
   const isPageExist = (pageId != null) && !isNotFound;
+  const isUsersTopPagePath = pagePath != null && isUsersTopPage(pagePath);
 
   return useSWRImmutable(
-    includesUndefined ? null : [key, pageId],
-    () => isPageExist && !isUserPage,
+    includesUndefined ? null : [key, pageId, pagePath, isNotFound],
+    () => isPageExist && !isUsersTopPagePath,
   );
 };

+ 0 - 9
packages/app/src/styles/_on-edit.scss

@@ -165,15 +165,6 @@ body.on-edit {
     }
   }
 
-  .nav:hover {
-    .btn-copy,
-    .btn-edit,
-    .btn-edit-tags {
-      // change button opacity
-      opacity: unset;
-    }
-  }
-
   .grw-copy-dropdown {
     .btn-copy {
       padding: 3px !important; // overwrite padding

+ 0 - 37
packages/app/src/styles/atoms/_buttons.scss

@@ -1,43 +1,6 @@
 @use '../bootstrap/init' as bs;
 @use '../mixins';
 
-.btn.btn-seen-user {
-  $color-seen-user: #549c79;
-
-  @include bs.button-outline-variant($color-seen-user, $color-seen-user, rgba(lighten($color-seen-user, 10%), 0.15), rgba(lighten($color-seen-user, 10%), 0.5));
-  @include mixins.button-outline-svg-icon-variant($color-seen-user, $color-seen-user);
-  &:not(:disabled):not(.disabled):active,
-  &:not(:disabled):not(.disabled).active {
-    color: $color-seen-user;
-    svg {
-      fill: $color-seen-user;
-    }
-  }
-  &:not(:disabled):not(.disabled):not(:hover) {
-    background-color: transparent;
-  }
-}
-
-.btn-copy,
-.btn-edit {
-  &:not(:hover):not(:active) {
-    background-color: transparent !important;
-  }
-  opacity: 0.5;
-}
-
-.btn-edit-tags {
-  opacity: 0.5;
-
-  &.no-tags {
-    opacity: 0.7;
-  }
-}
-
-.rounded-pill-weak {
-  border-radius: 60px;
-}
-
 // fill button style
 .btn.btn-fill {
   position: relative;

+ 2 - 0
packages/app/src/styles/atoms/_custom_control.scss

@@ -1,3 +1,5 @@
+@use '../bootstrap/init' as *;
+
 .custom-checkbox .custom-control-label::before {
   border-radius: $border-radius !important;
 }

+ 0 - 13
packages/app/src/styles/atoms/_nav.scss

@@ -1,13 +0,0 @@
-.nav-tabs .grw-main-nav-item-left {
-  width: $grw-nav-main-left-tab-width;
-  text-align: center;
-
-  @include media-breakpoint-down(sm) {
-    width: $grw-nav-main-left-tab-width-mobile;
-  }
-
-  .nav-link {
-    padding-right: 0;
-    padding-left: 0;
-  }
-}

+ 0 - 4
packages/app/src/styles/atoms/_pre.scss

@@ -1,4 +0,0 @@
-pre {
-  padding: 0.5em;
-  border-radius: $border-radius;
-}

+ 2 - 5
packages/app/src/styles/style-next.scss

@@ -27,11 +27,8 @@
 
 // atoms
 @import 'atoms/buttons';
-// @import 'atoms/code';
-// @import 'atoms/nav';
-// @import 'atoms/pre';
-// @import 'atoms/spinners';
-// @import 'atoms/custom_control';
+@import 'atoms/spinners';
+@import 'atoms/custom_control';
 
 // // molecules
 // @import 'molecules/copy-dropdown';

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

@@ -169,7 +169,7 @@ ul.pagination {
 
 .grw-navbar {
   background: $bgcolor-navbar;
-  .nav-item > .nav-link {
+  .nav-item .nav-link {
     color: $color-link-nabvar;
   }
 

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

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

+ 1 - 1
packages/core/package.json

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

+ 1 - 0
packages/core/src/interfaces/user.ts

@@ -20,6 +20,7 @@ export type IUser = {
   createdAt: Date,
   lastLoginAt?: Date,
   introduction: string,
+  status: number,
 }
 
 export type IUserGroupRelation = {

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

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

+ 6 - 11
packages/plugin-lsx/package.json

@@ -1,12 +1,9 @@
 {
   "name": "@growi/plugin-lsx",
-  "version": "5.1.5-RC.0",
+  "version": "6.0.0-RC.1",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
-  "keywords": [
-    "growi",
-    "growi-plugin"
-  ],
+  "keywords": ["growi", "growi-plugin"],
   "main": "dist/cjs/index.js",
   "module": "dist/esm/index.js",
   "exports": {
@@ -14,9 +11,7 @@
     "./services/renderer": "./dist/cjs/services/renderer/index.js",
     "./server/routes": "./dist/cjs/server/routes/index.js"
   },
-  "files": [
-    "dist"
-  ],
+  "files": ["dist"],
   "scripts": {
     "build": "run-p build:*",
     "build:cjs": "tsc -p tsconfig.build.cjs.json && tsc-alias -p tsconfig.build.cjs.json",
@@ -28,9 +23,9 @@
     "test": ""
   },
   "dependencies": {
-    "@growi/core": "^5.1.5-RC.0",
-    "@growi/remark-growi-plugin": "^5.1.5-RC.0",
-    "@growi/ui": "^5.1.5-RC.0"
+    "@growi/core": "^6.0.0-RC.1",
+    "@growi/remark-growi-plugin": "^6.0.0-RC.1",
+    "@growi/ui": "^6.0.0-RC.1"
   },
   "devDependencies": {
     "eslint-plugin-regex": "^1.8.0",

+ 1 - 1
packages/remark-growi-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-growi-plugin",
-  "version": "5.1.5-RC.0",
+  "version": "6.0.0-RC.1",
   "description": "remark plugin to support GROWI plugin (forked from remark-directive@2.0.1)",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "5.1.5-RC.0",
+  "version": "6.0.0-RC.1",
   "license": "MIT",
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",

+ 2 - 2
packages/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "5.1.5-slackbot-proxy.0",
+  "version": "6.0.0-slackbot-proxy.1",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -26,7 +26,7 @@
   },
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^5.1.5-RC.0",
+    "@growi/slack": "^6.0.0-RC.1",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",

+ 4 - 8
packages/ui/package.json

@@ -1,16 +1,12 @@
 {
   "name": "@growi/ui",
-  "version": "5.1.5-RC.0",
+  "version": "6.0.0-RC.1",
   "description": "GROWI UI Libraries",
   "license": "MIT",
-  "keywords": [
-    "growi"
-  ],
+  "keywords": ["growi"],
   "main": "dist/cjs/index.js",
   "module": "dist/esm/index.js",
-  "files": [
-    "dist"
-  ],
+  "files": ["dist"],
   "scripts": {
     "build": "run-p build:*",
     "build:cjs": "tsc -p tsconfig.build.cjs.json && tsc-alias -p tsconfig.build.cjs.json",
@@ -21,7 +17,7 @@
     "test": "jest --verbose"
   },
   "dependencies": {
-    "@growi/core": "^5.1.5-RC.0"
+    "@growi/core": "^6.0.0-RC.1"
   },
   "devDependencies": {
     "eslint-plugin-regex": "^1.8.0",

+ 3 - 1
packages/ui/src/components/Attachment/Attachment.jsx

@@ -60,7 +60,9 @@ export class Attachment extends React.Component {
         <span className="mr-1 attachment-userpicture">
           <UserPicture user={attachment.creator} size="sm"></UserPicture>
         </span>
-        <a className="mr-2" href={attachment.filePathProxied}><i className={formatIcon}></i> {attachment.originalName}</a>
+        <a className="mr-2" href={attachment.filePathProxied} target="_blank" rel="noopener noreferrer">
+          <i className={formatIcon}></i> {attachment.originalName}
+        </a>
         <span className="mr-2">{fileType}</span>
         <span className="mr-2">{fileInUse}</span>
         <span className="mr-2">{btnDownload}</span>

+ 31 - 30
yarn.lock

@@ -1441,13 +1441,6 @@
   dependencies:
     regenerator-runtime "^0.13.4"
 
-"@babel/runtime@^7.13.17", "@babel/runtime@^7.14.0", "@babel/runtime@^7.14.5", "@babel/runtime@^7.17.2":
-  version "7.18.6"
-  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.6.tgz#6a1ef59f838debd670421f8c7f2cbb8da9751580"
-  integrity sha512-t9wi7/AW6XtKahAe20Yw0/mMljKq0B1r2fPdvaAdV/KPDZewFXdaaa6K7lxmZBZ8FBNpCiAT6iHPmd6QO9bKfQ==
-  dependencies:
-    regenerator-runtime "^0.13.4"
-
 "@babel/runtime@^7.13.8", "@babel/runtime@^7.8.7":
   version "7.17.7"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.7.tgz#a5f3328dc41ff39d803f311cfe17703418cf9825"
@@ -1455,6 +1448,13 @@
   dependencies:
     regenerator-runtime "^0.13.4"
 
+"@babel/runtime@^7.14.0", "@babel/runtime@^7.14.5", "@babel/runtime@^7.17.2":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.6.tgz#6a1ef59f838debd670421f8c7f2cbb8da9751580"
+  integrity sha512-t9wi7/AW6XtKahAe20Yw0/mMljKq0B1r2fPdvaAdV/KPDZewFXdaaa6K7lxmZBZ8FBNpCiAT6iHPmd6QO9bKfQ==
+  dependencies:
+    regenerator-runtime "^0.13.4"
+
 "@babel/runtime@^7.14.6", "@babel/runtime@^7.15.4", "@babel/runtime@^7.9.2":
   version "7.16.7"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.7.tgz#03ff99f64106588c9c403c6ecb8c3bafbbdff1fa"
@@ -1462,6 +1462,13 @@
   dependencies:
     regenerator-runtime "^0.13.4"
 
+"@babel/runtime@^7.18.6":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.19.0.tgz#22b11c037b094d27a8a2504ea4dcff00f50e2259"
+  integrity sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==
+  dependencies:
+    regenerator-runtime "^0.13.4"
+
 "@babel/runtime@^7.6.3":
   version "7.7.7"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.7.7.tgz#194769ca8d6d7790ec23605af9ee3e42a0aa79cf"
@@ -11582,7 +11589,7 @@ hogan.js@3.0.2:
     mkdirp "0.3.0"
     nopt "1.0.10"
 
-hoist-non-react-statics@^3.2.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2:
+hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2:
   version "3.3.2"
   resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
   integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
@@ -11617,11 +11624,6 @@ html-escaper@^2.0.0:
   resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.0.tgz#71e87f931de3fe09e56661ab9a29aadec707b491"
   integrity sha512-a4u9BeERWGu/S8JiWEAQcdrg9v4QArtP9keViQjGMdff20fBdd8waotXaNmODqBe6uZ3Nafi7K/ho4gCQHV3Ig==
 
-html-escaper@^2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453"
-  integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==
-
 html-parse-stringify@^3.0.1:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz#dfc1017347ce9f77c8141a507f233040c59c55d2"
@@ -11807,10 +11809,10 @@ i18next-localstorage-backend@^3.1.3:
   dependencies:
     "@babel/runtime" "^7.14.6"
 
-i18next@^21.6.14:
-  version "21.8.11"
-  resolved "https://registry.yarnpkg.com/i18next/-/i18next-21.8.11.tgz#197aac04be51b7083999a2cb63deb831cf1ba4c8"
-  integrity sha512-+s8N6kQShwNK+Ua/+VsS/Sji24NUJJLBk9QIucygj1f97f4hPNDWmLP9fQCI4d5+XLfXJ3JctX4g+zJla967Vw==
+i18next@^21.8.13:
+  version "21.9.2"
+  resolved "https://registry.yarnpkg.com/i18next/-/i18next-21.9.2.tgz#3f7c5594393eb27117c1db4c38f5ec766e68de0e"
+  integrity sha512-00fVrLQOwy45nm3OtC9l1WiLK3nJlIYSljgCt0qzTaAy65aciMdRy9GsuW+a2AtKtdg9/njUGfRH30LRupV7ZQ==
   dependencies:
     "@babel/runtime" "^7.17.2"
 
@@ -16108,18 +16110,18 @@ nested-error-stacks@^2.0.0:
   resolved "https://registry.yarnpkg.com/nested-error-stacks/-/nested-error-stacks-2.1.0.tgz#0fbdcf3e13fe4994781280524f8b96b0cdff9c61"
   integrity sha512-AO81vsIO1k1sM4Zrd6Hu7regmJN1NSiAja10gc4bX3F0wd+9rQmcuHQaHVQCYIEC8iFXnE+mavh23GOt7wBgug==
 
-next-i18next@^11.0.0:
-  version "11.0.0"
-  resolved "https://registry.yarnpkg.com/next-i18next/-/next-i18next-11.0.0.tgz#2857d13c58a5ed976fe57c44286f1520b07f7c96"
-  integrity sha512-phxbQiZGSJTTBE2FI4+BnqFZl88AI2V+6MrEQnT9aPFAXq/fATQ/F0pOUM3J7kU4nEeCfn3hjISq+ygGHlEz0g==
+next-i18next@^11.3.0:
+  version "11.3.0"
+  resolved "https://registry.yarnpkg.com/next-i18next/-/next-i18next-11.3.0.tgz#bfce51d8df07fb5cd61097423eeb7d744e09ae25"
+  integrity sha512-xl0oIRtiVrk9ZaWBRUbNk/prva4Htdu59o9rFWzd9ax/KemaDVuTTuBZTQMkmXohUQk/MJ7w1rV/mICL6TzyGw==
   dependencies:
-    "@babel/runtime" "^7.13.17"
+    "@babel/runtime" "^7.18.6"
     "@types/hoist-non-react-statics" "^3.3.1"
     core-js "^3"
-    hoist-non-react-statics "^3.2.0"
-    i18next "^21.6.14"
+    hoist-non-react-statics "^3.3.2"
+    i18next "^21.8.13"
     i18next-fs-backend "^1.1.4"
-    react-i18next "^11.16.2"
+    react-i18next "^11.18.0"
 
 next-superjson@^0.0.4:
   version "0.0.4"
@@ -18450,13 +18452,12 @@ react-hotkeys@^2.0.0:
   dependencies:
     prop-types "^15.6.1"
 
-react-i18next@^11.16.2:
-  version "11.17.3"
-  resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-11.17.3.tgz#eff742f162f1a413056fb510e8b830d42c8c020d"
-  integrity sha512-rIrLl5cLDoHdXFWdjKurRpatA3MPC9j3yTZidv0GmJEea5+XGXl42p7NupA1dmghoLGOXllShNUobgPYtgEcRA==
+react-i18next@^11.18.0:
+  version "11.18.6"
+  resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-11.18.6.tgz#e159c2960c718c1314f1e8fcaa282d1c8b167887"
+  integrity sha512-yHb2F9BiT0lqoQDt8loZ5gWP331GwctHz9tYQ8A2EIEUu+CcEdjBLQWli1USG3RdWQt3W+jqQLg/d4rrQR96LA==
   dependencies:
     "@babel/runtime" "^7.14.5"
-    html-escaper "^2.0.2"
     html-parse-stringify "^3.0.1"
 
 react-image-crop@^8.3.0: