Bladeren bron

Merge branch 'master' into fix/gw7961-cancel-a-comment-will-cancel-all-comments

Mudana-Grune 2 jaren geleden
bovenliggende
commit
0cb6cfc0ed
100 gewijzigde bestanden met toevoegingen van 826 en 300 verwijderingen
  1. 2 2
      .github/workflows/release-slackbot-proxy.yml
  2. 5 3
      .github/workflows/release.yml
  3. 17 1
      CHANGELOG.md
  4. 1 0
      apps/app/next.config.js
  5. 13 12
      apps/app/package.json
  6. 11 0
      apps/app/public/images/icons/editor/attachment.svg
  7. 3 1
      apps/app/public/static/locales/en_US/commons.json
  8. 6 0
      apps/app/public/static/locales/en_US/translation.json
  9. 6 1
      apps/app/public/static/locales/ja_JP/commons.json
  10. 6 0
      apps/app/public/static/locales/ja_JP/translation.json
  11. 6 1
      apps/app/public/static/locales/zh_CN/commons.json
  12. 6 0
      apps/app/public/static/locales/zh_CN/translation.json
  13. 0 0
      apps/app/resource/locales/en_US/admin/userInvitation.ejs
  14. 0 0
      apps/app/resource/locales/en_US/admin/userResetPassword.ejs
  15. 0 0
      apps/app/resource/locales/en_US/admin/userWaitingActivation.ejs
  16. 1 1
      apps/app/resource/locales/en_US/notifications/comment.ejs
  17. 0 0
      apps/app/resource/locales/en_US/notifications/notActiveUser.ejs
  18. 1 1
      apps/app/resource/locales/en_US/notifications/pageCreate.ejs
  19. 1 1
      apps/app/resource/locales/en_US/notifications/pageDelete.ejs
  20. 1 1
      apps/app/resource/locales/en_US/notifications/pageEdit.ejs
  21. 1 1
      apps/app/resource/locales/en_US/notifications/pageLike.ejs
  22. 1 1
      apps/app/resource/locales/en_US/notifications/pageMove.ejs
  23. 0 0
      apps/app/resource/locales/en_US/notifications/passwordReset.ejs
  24. 0 0
      apps/app/resource/locales/en_US/notifications/passwordResetSuccessful.ejs
  25. 0 0
      apps/app/resource/locales/en_US/notifications/userActivation.ejs
  26. 1 1
      apps/app/resource/locales/en_US/welcome.md
  27. 0 0
      apps/app/resource/locales/ja_JP/admin/userInvitation.ejs
  28. 0 0
      apps/app/resource/locales/ja_JP/admin/userResetPassword.ejs
  29. 0 0
      apps/app/resource/locales/ja_JP/admin/userWaitingActivation.ejs
  30. 9 0
      apps/app/resource/locales/ja_JP/notifications/comment.ejs
  31. 0 0
      apps/app/resource/locales/ja_JP/notifications/notActiveUser.ejs
  32. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageCreate.ejs
  33. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageCreate.txt
  34. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageDelete.ejs
  35. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageDelete.txt
  36. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageEdit.ejs
  37. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageEdit.txt
  38. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageLike.ejs
  39. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageLike.txt
  40. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageMove.ejs
  41. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageMove.txt
  42. 0 0
      apps/app/resource/locales/ja_JP/notifications/passwordReset.ejs
  43. 0 0
      apps/app/resource/locales/ja_JP/notifications/passwordResetSuccessful.ejs
  44. 0 0
      apps/app/resource/locales/ja_JP/notifications/userActivation.ejs
  45. 0 0
      apps/app/resource/locales/zh_CN/admin/userInvitation.ejs
  46. 0 0
      apps/app/resource/locales/zh_CN/admin/userResetPassword.ejs
  47. 0 0
      apps/app/resource/locales/zh_CN/admin/userWaitingActivation.ejs
  48. 1 1
      apps/app/resource/locales/zh_CN/notifications/comment.ejs
  49. 0 0
      apps/app/resource/locales/zh_CN/notifications/notActiveUser.ejs
  50. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageCreate.ejs
  51. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageDelete.ejs
  52. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageEdit.ejs
  53. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageLike.ejs
  54. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageMove.ejs
  55. 0 0
      apps/app/resource/locales/zh_CN/notifications/passwordReset.ejs
  56. 0 0
      apps/app/resource/locales/zh_CN/notifications/passwordResetSuccessful.ejs
  57. 0 0
      apps/app/resource/locales/zh_CN/notifications/userActivation.ejs
  58. 11 1
      apps/app/src/client/services/renderer/renderer.tsx
  59. 11 10
      apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  60. 9 2
      apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  61. 103 10
      apps/app/src/components/Admin/Users/PasswordResetModal.jsx
  62. 2 0
      apps/app/src/components/Layout/BasicLayout.tsx
  63. 11 2
      apps/app/src/components/Me/BasicInfoSettings.tsx
  64. 10 11
      apps/app/src/components/Page/TagEditModal.tsx
  65. 7 57
      apps/app/src/components/PageAttachment.tsx
  66. 65 48
      apps/app/src/components/PageAttachment/DeleteAttachmentModal.tsx
  67. 1 1
      apps/app/src/components/PageAttachment/PageAttachmentList.tsx
  68. 31 31
      apps/app/src/components/PageEditor/HandsontableModal.tsx
  69. 0 17
      apps/app/src/components/ReactMarkdownComponents/NextLink.tsx
  70. 7 0
      apps/app/src/components/ReactMarkdownComponents/RichAttachment.module.scss
  71. 84 0
      apps/app/src/components/ReactMarkdownComponents/RichAttachment.tsx
  72. 1 1
      apps/app/src/components/ReactMarkdownComponents/TableWithEditButton.tsx
  73. 20 4
      apps/app/src/components/SearchPage.tsx
  74. 14 5
      apps/app/src/components/SearchPage/SearchPageBase.tsx
  75. 3 2
      apps/app/src/components/Sidebar/SidebarNav.tsx
  76. 1 6
      apps/app/src/components/User/Username.tsx
  77. 2 1
      apps/app/src/pages/[[...path]].page.tsx
  78. 2 1
      apps/app/src/pages/_private-legacy-pages.page.tsx
  79. 2 1
      apps/app/src/pages/_search.page.tsx
  80. 2 1
      apps/app/src/pages/me/[[...path]].page.tsx
  81. 2 1
      apps/app/src/pages/tags.page.tsx
  82. 3 1
      apps/app/src/pages/trash.page.tsx
  83. 2 0
      apps/app/src/pages/utils/commons.ts
  84. 16 0
      apps/app/src/server/models/user.js
  85. 45 0
      apps/app/src/server/routes/apiv3/attachment.js
  86. 8 8
      apps/app/src/server/routes/apiv3/forgot-password.js
  87. 9 0
      apps/app/src/server/routes/apiv3/personal-setting.js
  88. 6 9
      apps/app/src/server/routes/apiv3/user-activation.ts
  89. 68 3
      apps/app/src/server/routes/apiv3/users.js
  90. 4 3
      apps/app/src/server/routes/login.js
  91. 3 2
      apps/app/src/server/service/global-notification/global-notification-mail.js
  92. 44 0
      apps/app/src/services/renderer/remark-plugins/attachment.ts
  93. 31 3
      apps/app/src/stores/attachment.tsx
  94. 46 0
      apps/app/src/stores/modal.tsx
  95. 5 3
      apps/app/src/stores/personal-settings.tsx
  96. 2 2
      apps/app/src/styles/theme/apply-colors.scss
  97. 0 1
      apps/app/tsconfig.json
  98. 0 11
      apps/slackbot-proxy/bump-versions.config.js
  99. 4 3
      apps/slackbot-proxy/package.json
  100. 0 4
      bin/github-actions/bump-versions/README.md

+ 2 - 2
.github/workflows/release-slackbot-proxy.yml

@@ -113,7 +113,7 @@ jobs:
 
 
     - name: Bump versions for next RC
     - name: Bump versions for next RC
       run: |
       run: |
-        yarn bump-versions:slackbot-proxy
+        turbo run version --filter=@growi/slackbot-proxy -- --prerelease
 
 
     - name: Retrieve information from package.json
     - name: Retrieve information from package.json
       uses: myrotvorets/info-from-package-json-action@1.2.0
       uses: myrotvorets/info-from-package-json-action@1.2.0
@@ -135,6 +135,6 @@ jobs:
         source_branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
         source_branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
         destination_branch: master
         destination_branch: master
         pr_title: Prepare v${{ steps.package-json.outputs.packageVersion }}
         pr_title: Prepare v${{ steps.package-json.outputs.packageVersion }}
-        pr_label: flag/exclude-from-changelog
+        pr_label: flag/exclude-from-changelog,type/prepare-next-version
         pr_body: "An automated PR generated by ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
         pr_body: "An automated PR generated by ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
         github_token: ${{ secrets.GITHUB_TOKEN }}
         github_token: ${{ secrets.GITHUB_TOKEN }}

+ 5 - 3
.github/workflows/release.yml

@@ -35,7 +35,8 @@ jobs:
 
 
     - name: Bump versions
     - name: Bump versions
       run: |
       run: |
-        yarn bump-versions:patch
+        turbo run version --filter=@growi/app -- --patch
+        yarn upgrade --scope=@growi
         sh ./apps/app/bin/github-actions/update-readme.sh
         sh ./apps/app/bin/github-actions/update-readme.sh
 
 
     - name: Retrieve information from package.json
     - name: Retrieve information from package.json
@@ -97,8 +98,9 @@ jobs:
 
 
     - name: Bump versions for next RC
     - name: Bump versions for next RC
       run: |
       run: |
-        yarn bump-versions:rc
-        yarn bump-versions:slackbot-proxy
+        turbo run version --filter=@growi/app -- --prepatch
+        turbo run version --filter=@growi/slackbot-proxy -- --prepatch
+        yarn upgrade --scope=@growi
 
 
     - name: Retrieve information from package.json
     - name: Retrieve information from package.json
       uses: myrotvorets/info-from-package-json-action@1.2.0
       uses: myrotvorets/info-from-package-json-action@1.2.0

+ 17 - 1
CHANGELOG.md

@@ -1,9 +1,25 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v6.1.3...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v6.1.4...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v6.1.4](https://github.com/weseek/growi/compare/v6.1.3...v6.1.4) - 2023-06-12
+
+### 💎 Features
+
+- feat(plugin): Specify repository branch name (#7783) @yuki-takei
+
+### 🚀 Improvement
+
+- imprv: Suppress unnecessary bookmark API requests (#7798) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- fix: Bookmarks mutation for the current user (#7797) @yuki-takei
+- fix: Slack channels data for User Triggered Notification is not loaded (#7794) @yuki-takei
+- fix: The input of the editor is cleared when an attachment is added when a new page editing (#7788) @miya
+
 ## [v6.1.3](https://github.com/weseek/growi/compare/v6.1.2...v6.1.3) - 2023-06-07
 ## [v6.1.3](https://github.com/weseek/growi/compare/v6.1.2...v6.1.3) - 2023-06-07
 
 
 ### 💎 Features
 ### 💎 Features

+ 1 - 0
apps/app/next.config.js

@@ -30,6 +30,7 @@ const getTranspilePackages = () => {
     'hastscript',
     'hastscript',
     'html-void-elements',
     'html-void-elements',
     'is-absolute-url',
     'is-absolute-url',
+    'is-plain-obj',
     'longest-streak',
     'longest-streak',
     'micromark',
     'micromark',
     'property-information',
     'property-information',

+ 13 - 12
apps/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "6.1.4-RC.0",
+  "version": "6.1.5-RC.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
@@ -46,7 +46,8 @@
     "openapi:v3": "yarn cross-env API_VERSION=3 yarn swagger-jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
     "openapi:v3": "yarn cross-env API_VERSION=3 yarn swagger-jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
     "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
     "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
     "ts-node": "node -r ts-node/register -r tsconfig-paths/register -r dotenv-flow/config",
     "ts-node": "node -r ts-node/register -r tsconfig-paths/register -r dotenv-flow/config",
-    "ts-node-dev": "ts-node-dev -r tsconfig-paths/register -r dotenv-flow/config"
+    "ts-node-dev": "ts-node-dev -r tsconfig-paths/register -r dotenv-flow/config",
+    "version": "yarn version --no-git-tag-version --preid=RC"
   },
   },
   "// comments for dependencies": {
   "// comments for dependencies": {
     "escape-string-regexp": "5.0.0 or above exports only ESM",
     "escape-string-regexp": "5.0.0 or above exports only ESM",
@@ -63,14 +64,14 @@
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/core": "^6.1.4-RC.0",
-    "@growi/hackmd": "^6.1.4-RC.0",
-    "@growi/preset-themes": "^6.1.4-RC.0",
-    "@growi/remark-attachment-refs": "^6.1.4-RC.0",
-    "@growi/remark-drawio": "^6.1.4-RC.0",
-    "@growi/remark-growi-directive": "^6.1.4-RC.0",
-    "@growi/remark-lsx": "^6.1.4-RC.0",
-    "@growi/slack": "^6.1.4-RC.0",
+    "@growi/core": "link:../../packages/core",
+    "@growi/hackmd": "link:../../packages/hackmd",
+    "@growi/preset-themes": "link:../../packages/preset-themes",
+    "@growi/remark-attachment-refs": "link:../../packages/remark-attachment-refs",
+    "@growi/remark-drawio": "link:../../packages/remark-drawio",
+    "@growi/remark-growi-directive": "link:../../packages/remark-growi-directive",
+    "@growi/remark-lsx": "link:../../packages/remark-lsx",
+    "@growi/slack": "link:../../packages/slack",
     "@promster/express": "^7.0.6",
     "@promster/express": "^7.0.6",
     "@promster/server": "^7.0.8",
     "@promster/server": "^7.0.8",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
@@ -208,8 +209,8 @@
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
   },
   },
   "devDependencies": {
   "devDependencies": {
-    "@growi/presentation": "^6.1.4-RC.0",
-    "@growi/ui": "^6.1.4-RC.0",
+    "@growi/presentation": "link:../../packages/presentation",
+    "@growi/ui": "link:../../packages/ui",
     "@handsontable/react": "=2.1.0",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^13.2.3",
     "@next/bundle-analyzer": "^13.2.3",

+ 11 - 0
apps/app/public/images/icons/editor/attachment.svg

@@ -0,0 +1,11 @@
+<svg id="group_5327" data-name="group 5327" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="28.093" viewBox="0 0 24 28.093">
+  <defs>
+    <clipPath id="clip-path">
+      <rect id="rectangle_1922" data-name="rectangle 1922" width="24" height="28.093" fill="#6c757d" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+    </clipPath>
+  </defs>
+  <g id="group_5319" data-name="group 5319" clip-path="url(#clip-path)">
+    <path id="pass_4850" data-name="pass 4850" d="M20.6,16.976l-.651,1.17a4.292,4.292,0,0,1-.828,1.031V21H13.7v5.619H1.479V1.479H19.123v2a1.932,1.932,0,0,1,.2.094l1.282.714V0H0V28.093H15.18v0h0L20.6,22.48l-.006-.006H20.6ZM15.18,25.957V22.474h3.369Z" fill="#6c757d" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+    <path id="pass_4851" data-name="pass 4851" d="M203.477,65.236a.648.648,0,0,1,.509.96l-5.117,9.2a3.483,3.483,0,0,0,1.537,4.427,3.8,3.8,0,0,0,3.11.3,3.293,3.293,0,0,0,1.744-1.212l4.784-8.6-3.846-2.14L201.727,76.2c0,.007-.36.684.2,1a.825.825,0,0,0,.689.1.9.9,0,0,0,.461-.417l3.591-6.454,1.131.629-3.592,6.454a2.176,2.176,0,0,1-1.158,1.008,2.074,2.074,0,0,1-1.752-.19,1.832,1.832,0,0,1-.973-1.509,2.366,2.366,0,0,1,.271-1.248l4.786-8.6a.647.647,0,0,1,.88-.251l4.978,2.77a.647.647,0,0,1,.251.88l-5.1,9.163a4.531,4.531,0,0,1-2.469,1.811,5.062,5.062,0,0,1-4.146-.4,4.767,4.767,0,0,1-2.039-6.188l5.117-9.2a.648.648,0,0,1,.622-.33" transform="translate(-187.572 -62.019)" fill="#6c757d" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+  </g>
+</svg>

+ 3 - 1
apps/app/public/static/locales/en_US/commons.json

@@ -6,6 +6,7 @@
   "Reset": "Reset",
   "Reset": "Reset",
   "Sign out": "Logout",
   "Sign out": "Logout",
   "New": "New",
   "New": "New",
+  "Delete": "Delete",
 
 
   "meta": {
   "meta": {
     "display_name": "English"
     "display_name": "English"
@@ -23,7 +24,8 @@
   "alert": {
   "alert": {
     "siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the {{link}}",
     "siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the {{link}}",
     "please_enable_mailer": "Please setup mailer first.",
     "please_enable_mailer": "Please setup mailer first.",
-    "password_reset_please_enable_mailer": "Please setup mailer first."
+    "password_reset_please_enable_mailer": "Please setup mailer first.",
+    "email_is_already_in_use": "The email address is already in use."
   },
   },
   "headers": {
   "headers": {
     "app_settings": "App Settings"
     "app_settings": "App Settings"

+ 6 - 0
apps/app/public/static/locales/en_US/translation.json

@@ -820,5 +820,11 @@
     "tags_input": {
     "tags_input": {
       "tag_name": "tag name"
       "tag_name": "tag name"
     }
     }
+  },
+  "delete_attachment_modal": {
+    "confirm_delete_attachment": "Delete attachment?"
+  },
+  "rich_attachment": {
+    "attachment_not_be_found": "The attachment could not be found"
   }
   }
 }
 }

+ 6 - 1
apps/app/public/static/locales/ja_JP/commons.json

@@ -6,6 +6,10 @@
   "Reset": "リセット",
   "Reset": "リセット",
   "Sign out": "ログアウト",
   "Sign out": "ログアウト",
   "New": "作成",
   "New": "作成",
+  "Send": "送信",
+  "Close": "閉じる",
+  "Done": "完了",
+  "Delete": "削除",
   "meta": {
   "meta": {
     "display_name": "日本語"
     "display_name": "日本語"
   },
   },
@@ -22,7 +26,8 @@
   "alert": {
   "alert": {
     "siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",
     "siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",
     "please_enable_mailer": "メール認証を有効にするには、メール設定を完了させてください。",
     "please_enable_mailer": "メール認証を有効にするには、メール設定を完了させてください。",
-    "password_reset_please_enable_mailer": "パスワード再設定を有効にするには、メール設定を完了させてください。"
+    "password_reset_please_enable_mailer": "パスワード再設定を有効にするには、メール設定を完了させてください。",
+    "email_is_already_in_use": "そのメールアドレスは既に使用されています。"
   },
   },
   "headers": {
   "headers": {
     "app_settings": "アプリ設定"
     "app_settings": "アプリ設定"

+ 6 - 0
apps/app/public/static/locales/ja_JP/translation.json

@@ -853,5 +853,11 @@
     "tags_input": {
     "tags_input": {
       "tag_name": "タグ名"
       "tag_name": "タグ名"
     }
     }
+  },
+  "delete_attachment_modal": {
+    "confirm_delete_attachment": "アタッチメントを削除しますか?"
+  },
+  "rich_attachment": {
+    "attachment_not_be_found": "アタッチメントが見つかりません"
   }
   }
 }
 }

+ 6 - 1
apps/app/public/static/locales/zh_CN/commons.json

@@ -6,6 +6,10 @@
   "Reset": "重启",
   "Reset": "重启",
 	"Sign out": "退出",
 	"Sign out": "退出",
   "New": "新建",
   "New": "新建",
+  "Send": "发送",
+  "Close": "关闭",
+  "Done": "完成",
+  "Delete": "删除",
 
 
   "meta": {
   "meta": {
     "display_name": "简体中文"
     "display_name": "简体中文"
@@ -23,7 +27,8 @@
   "alert": {
   "alert": {
     "siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",
     "siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",
     "please_enable_mailer": "请先设置邮件程序。",
     "please_enable_mailer": "请先设置邮件程序。",
-    "password_reset_please_enable_mailer": "请先设置邮件程序。"
+    "password_reset_please_enable_mailer": "请先设置邮件程序。",
+    "email_is_already_in_use": "这个电子邮件地址已经在使用了。"
   },
   },
   "headers": {
   "headers": {
     "app_settings": "系统设置"
     "app_settings": "系统设置"

+ 6 - 0
apps/app/public/static/locales/zh_CN/translation.json

@@ -823,5 +823,11 @@
     "tags_input": {
     "tags_input": {
       "tag_name": "标签名称"
       "tag_name": "标签名称"
     }
     }
+  },
+  "delete_attachment_modal": {
+    "confirm_delete_attachment": "你想删除一个附件吗?"
+  },
+  "rich_attachment": {
+    "attachment_not_be_found": "没有找到附件"
   }
   }
 }
 }

+ 0 - 0
apps/app/resource/locales/en_US/admin/userInvitation.txt → apps/app/resource/locales/en_US/admin/userInvitation.ejs


+ 0 - 0
apps/app/resource/locales/en_US/admin/userResetPassword.txt → apps/app/resource/locales/en_US/admin/userResetPassword.ejs


+ 0 - 0
apps/app/resource/locales/en_US/admin/userWaitingActivation.txt → apps/app/resource/locales/en_US/admin/userWaitingActivation.ejs


+ 1 - 1
apps/app/resource/locales/en_US/notifications/comment.txt → apps/app/resource/locales/en_US/notifications/comment.ejs

@@ -6,4 +6,4 @@
 
 
 ----------------------
 ----------------------
 
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/en_US/notifications/notActiveUser.txt → apps/app/resource/locales/en_US/notifications/notActiveUser.ejs


+ 1 - 1
apps/app/resource/locales/zh_CN/notifications/pageCreate.txt → apps/app/resource/locales/en_US/notifications/pageCreate.ejs

@@ -2,4 +2,4 @@
 
 
 ----------------------
 ----------------------
 
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 1 - 1
apps/app/resource/locales/zh_CN/notifications/pageDelete.txt → apps/app/resource/locales/en_US/notifications/pageDelete.ejs

@@ -2,4 +2,4 @@
 
 
 ----------------------
 ----------------------
 
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 1 - 1
apps/app/resource/locales/zh_CN/notifications/pageEdit.txt → apps/app/resource/locales/en_US/notifications/pageEdit.ejs

@@ -2,4 +2,4 @@
 
 
 ----------------------
 ----------------------
 
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 1 - 1
apps/app/resource/locales/en_US/notifications/pageLike.txt → apps/app/resource/locales/en_US/notifications/pageLike.ejs

@@ -2,4 +2,4 @@
 
 
 ----------------------
 ----------------------
 
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 1 - 1
apps/app/resource/locales/en_US/notifications/pageMove.txt → apps/app/resource/locales/en_US/notifications/pageMove.ejs

@@ -2,4 +2,4 @@
 
 
 ----------------------
 ----------------------
 
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/en_US/notifications/passwordReset.txt → apps/app/resource/locales/en_US/notifications/passwordReset.ejs


+ 0 - 0
apps/app/resource/locales/en_US/notifications/passwordResetSuccessful.txt → apps/app/resource/locales/en_US/notifications/passwordResetSuccessful.ejs


+ 0 - 0
apps/app/resource/locales/en_US/notifications/userActivation.txt → apps/app/resource/locales/en_US/notifications/userActivation.ejs


+ 1 - 1
apps/app/resource/locales/en_US/welcome.md

@@ -60,5 +60,5 @@ We can display the content list using a table and `$lsx`.
 
 
 <a href="https://growi-slackin.weseek.co.jp/"><img src="https://growi-slackin.weseek.co.jp/badge.svg"></a>
 <a href="https://growi-slackin.weseek.co.jp/"><img src="https://growi-slackin.weseek.co.jp/badge.svg"></a>
 
 
-We welcome newcomers joining our slack channel to help improve Growi.
+We welcome newcomers joining our slack channel to help improve GROWI.
 In addition to discussing development, we are also happy to answer your questions when you join.
 In addition to discussing development, we are also happy to answer your questions when you join.

+ 0 - 0
apps/app/resource/locales/ja_JP/admin/userInvitation.txt → apps/app/resource/locales/ja_JP/admin/userInvitation.ejs


+ 0 - 0
apps/app/resource/locales/ja_JP/admin/userResetPassword.txt → apps/app/resource/locales/ja_JP/admin/userResetPassword.ejs


+ 0 - 0
apps/app/resource/locales/ja_JP/admin/userWaitingActivation.txt → apps/app/resource/locales/ja_JP/admin/userWaitingActivation.ejs


+ 9 - 0
apps/app/resource/locales/ja_JP/notifications/comment.ejs

@@ -0,0 +1,9 @@
+<%- username %> が <%- path %> にコメントしました。
+
+----------------------
+
+<%- comment %>
+
+----------------------
+
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/notActiveUser.txt → apps/app/resource/locales/ja_JP/notifications/notActiveUser.ejs


+ 5 - 0
apps/app/resource/locales/ja_JP/notifications/pageCreate.ejs

@@ -0,0 +1,5 @@
+<%- username %> が <%- path %> を作成しました。
+
+----------------------
+
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/pageCreate.txt


+ 5 - 0
apps/app/resource/locales/ja_JP/notifications/pageDelete.ejs

@@ -0,0 +1,5 @@
+<%- username %> が <%- path %> を削除しました。
+
+----------------------
+
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/pageDelete.txt


+ 5 - 0
apps/app/resource/locales/ja_JP/notifications/pageEdit.ejs

@@ -0,0 +1,5 @@
+<%- username %> が <%- path %> を編集しました。
+
+----------------------
+
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/pageEdit.txt


+ 5 - 0
apps/app/resource/locales/ja_JP/notifications/pageLike.ejs

@@ -0,0 +1,5 @@
+<%- username %> が <%- path %> を「いいね」しました。
+
+----------------------
+
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/pageLike.txt


+ 5 - 0
apps/app/resource/locales/ja_JP/notifications/pageMove.ejs

@@ -0,0 +1,5 @@
+<%- username %> が <%- oldPath %> を <%- newPath %> に移動(名前を変更)しました。
+
+----------------------
+
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/pageMove.txt


+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/passwordReset.txt → apps/app/resource/locales/ja_JP/notifications/passwordReset.ejs


+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/passwordResetSuccessful.txt → apps/app/resource/locales/ja_JP/notifications/passwordResetSuccessful.ejs


+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/userActivation.txt → apps/app/resource/locales/ja_JP/notifications/userActivation.ejs


+ 0 - 0
apps/app/resource/locales/zh_CN/admin/userInvitation.txt → apps/app/resource/locales/zh_CN/admin/userInvitation.ejs


+ 0 - 0
apps/app/resource/locales/zh_CN/admin/userResetPassword.txt → apps/app/resource/locales/zh_CN/admin/userResetPassword.ejs


+ 0 - 0
apps/app/resource/locales/zh_CN/admin/userWaitingActivation.txt → apps/app/resource/locales/zh_CN/admin/userWaitingActivation.ejs


+ 1 - 1
apps/app/resource/locales/zh_CN/notifications/comment.txt → apps/app/resource/locales/zh_CN/notifications/comment.ejs

@@ -6,4 +6,4 @@
 
 
 ----------------------
 ----------------------
 
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/zh_CN/notifications/notActiveUser.txt → apps/app/resource/locales/zh_CN/notifications/notActiveUser.ejs


+ 1 - 1
apps/app/resource/locales/en_US/notifications/pageCreate.txt → apps/app/resource/locales/zh_CN/notifications/pageCreate.ejs

@@ -2,4 +2,4 @@
 
 
 ----------------------
 ----------------------
 
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 1 - 1
apps/app/resource/locales/en_US/notifications/pageDelete.txt → apps/app/resource/locales/zh_CN/notifications/pageDelete.ejs

@@ -2,4 +2,4 @@
 
 
 ----------------------
 ----------------------
 
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 1 - 1
apps/app/resource/locales/en_US/notifications/pageEdit.txt → apps/app/resource/locales/zh_CN/notifications/pageEdit.ejs

@@ -2,4 +2,4 @@
 
 
 ----------------------
 ----------------------
 
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 1 - 1
apps/app/resource/locales/zh_CN/notifications/pageLike.txt → apps/app/resource/locales/zh_CN/notifications/pageLike.ejs

@@ -2,4 +2,4 @@
 
 
 ----------------------
 ----------------------
 
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 1 - 1
apps/app/resource/locales/zh_CN/notifications/pageMove.txt → apps/app/resource/locales/zh_CN/notifications/pageMove.ejs

@@ -2,4 +2,4 @@
 
 
 ----------------------
 ----------------------
 
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/zh_CN/notifications/passwordReset.txt → apps/app/resource/locales/zh_CN/notifications/passwordReset.ejs


+ 0 - 0
apps/app/resource/locales/zh_CN/notifications/passwordResetSuccessful.txt → apps/app/resource/locales/zh_CN/notifications/passwordResetSuccessful.ejs


+ 0 - 0
apps/app/resource/locales/zh_CN/notifications/userActivation.txt → apps/app/resource/locales/zh_CN/notifications/userActivation.ejs


+ 11 - 1
apps/app/src/client/services/renderer/renderer.tsx

@@ -14,9 +14,9 @@ import math from 'remark-math';
 import deepmerge from 'ts-deepmerge';
 import deepmerge from 'ts-deepmerge';
 import type { Pluggable } from 'unified';
 import type { Pluggable } from 'unified';
 
 
-
 import { DrawioViewerWithEditButton } from '~/components/ReactMarkdownComponents/DrawioViewerWithEditButton';
 import { DrawioViewerWithEditButton } from '~/components/ReactMarkdownComponents/DrawioViewerWithEditButton';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
+import { RichAttachment } from '~/components/ReactMarkdownComponents/RichAttachment';
 import { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton';
 import { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton';
 import * as mermaid from '~/features/mermaid';
 import * as mermaid from '~/features/mermaid';
 import { RehypeSanitizeOption } from '~/interfaces/rehype';
 import { RehypeSanitizeOption } from '~/interfaces/rehype';
@@ -25,6 +25,7 @@ import type { RendererConfig } from '~/interfaces/services/renderer';
 import * as addLineNumberAttribute from '~/services/renderer/rehype-plugins/add-line-number-attribute';
 import * as addLineNumberAttribute from '~/services/renderer/rehype-plugins/add-line-number-attribute';
 import * as keywordHighlighter from '~/services/renderer/rehype-plugins/keyword-highlighter';
 import * as keywordHighlighter from '~/services/renderer/rehype-plugins/keyword-highlighter';
 import * as relocateToc from '~/services/renderer/rehype-plugins/relocate-toc';
 import * as relocateToc from '~/services/renderer/rehype-plugins/relocate-toc';
+import * as attachment from '~/services/renderer/remark-plugins/attachment';
 import * as plantuml from '~/services/renderer/remark-plugins/plantuml';
 import * as plantuml from '~/services/renderer/remark-plugins/plantuml';
 import * as xsvToTable from '~/services/renderer/remark-plugins/xsv-to-table';
 import * as xsvToTable from '~/services/renderer/remark-plugins/xsv-to-table';
 import {
 import {
@@ -61,6 +62,7 @@ export const generateViewOptions = (
     drawio.remarkPlugin,
     drawio.remarkPlugin,
     mermaid.remarkPlugin,
     mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
     xsvToTable.remarkPlugin,
+    attachment.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
   );
   );
@@ -77,6 +79,7 @@ export const generateViewOptions = (
       commonSanitizeOption,
       commonSanitizeOption,
       drawio.sanitizeOption,
       drawio.sanitizeOption,
       mermaid.sanitizeOption,
       mermaid.sanitizeOption,
+      attachment.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
     )]
     )]
@@ -109,6 +112,7 @@ export const generateViewOptions = (
     components.drawio = DrawioViewerWithEditButton;
     components.drawio = DrawioViewerWithEditButton;
     components.table = TableWithEditButton;
     components.table = TableWithEditButton;
     components.mermaid = mermaid.MermaidViewer;
     components.mermaid = mermaid.MermaidViewer;
+    components.attachment = RichAttachment;
   }
   }
 
 
   if (config.isEnabledXssPrevention) {
   if (config.isEnabledXssPrevention) {
@@ -167,6 +171,7 @@ export const generateSimpleViewOptions = (
     drawio.remarkPlugin,
     drawio.remarkPlugin,
     mermaid.remarkPlugin,
     mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
     xsvToTable.remarkPlugin,
+    attachment.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
   );
   );
@@ -187,6 +192,7 @@ export const generateSimpleViewOptions = (
       commonSanitizeOption,
       commonSanitizeOption,
       drawio.sanitizeOption,
       drawio.sanitizeOption,
       mermaid.sanitizeOption,
       mermaid.sanitizeOption,
+      attachment.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
     )]
     )]
@@ -211,6 +217,7 @@ export const generateSimpleViewOptions = (
     components.gallery = refsGrowiDirective.GalleryImmutable;
     components.gallery = refsGrowiDirective.GalleryImmutable;
     components.drawio = drawio.DrawioViewer;
     components.drawio = drawio.DrawioViewer;
     components.mermaid = mermaid.MermaidViewer;
     components.mermaid = mermaid.MermaidViewer;
+    components.attachment = RichAttachment;
   }
   }
 
 
   if (config.isEnabledXssPrevention) {
   if (config.isEnabledXssPrevention) {
@@ -244,6 +251,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     drawio.remarkPlugin,
     drawio.remarkPlugin,
     mermaid.remarkPlugin,
     mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
     xsvToTable.remarkPlugin,
+    attachment.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
   );
   );
@@ -260,6 +268,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
       commonSanitizeOption,
       commonSanitizeOption,
       drawio.sanitizeOption,
       drawio.sanitizeOption,
       mermaid.sanitizeOption,
       mermaid.sanitizeOption,
+      attachment.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
@@ -285,6 +294,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     components.gallery = refsGrowiDirective.GalleryImmutable;
     components.gallery = refsGrowiDirective.GalleryImmutable;
     components.drawio = drawio.DrawioViewer;
     components.drawio = drawio.DrawioViewer;
     components.mermaid = mermaid.MermaidViewer;
     components.mermaid = mermaid.MermaidViewer;
+    components.attachment = RichAttachment;
   }
   }
 
 
   if (config.isEnabledXssPrevention) {
   if (config.isEnabledXssPrevention) {

+ 11 - 10
apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -7,9 +7,10 @@ import { IUserGroupHasId } from '~/interfaces/user';
 
 
 type Props = {
 type Props = {
   userGroup: IUserGroupHasId,
   userGroup: IUserGroupHasId,
+  parentUserGroup?: IUserGroupHasId,
   selectableParentUserGroups?: IUserGroupHasId[],
   selectableParentUserGroups?: IUserGroupHasId[],
   submitButtonLabel: string;
   submitButtonLabel: string;
-  onSubmit?: (targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>) => Promise<void> | void
+  onSubmit: (targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>) => Promise<void>
 };
 };
 
 
 export const UserGroupForm: FC<Props> = (props: Props) => {
 export const UserGroupForm: FC<Props> = (props: Props) => {
@@ -17,16 +18,14 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
   const {
   const {
-    userGroup, selectableParentUserGroups, submitButtonLabel, onSubmit,
+    userGroup, parentUserGroup, selectableParentUserGroups, submitButtonLabel, onSubmit,
   } = props;
   } = props;
-
   /*
   /*
    * State
    * State
    */
    */
-  const [currentName, setName] = useState(userGroup != null ? userGroup.name : '');
-  const [currentDescription, setDescription] = useState(userGroup != null ? userGroup.description : '');
-  const [selectedParent, setSelectedParent] = useState<IUserGroupHasId | undefined>(userGroup?.parent as IUserGroupHasId);
-
+  const [currentName, setName] = useState<string>(userGroup.name);
+  const [currentDescription, setDescription] = useState<string>(userGroup.description);
+  const [selectedParent, setSelectedParent] = useState<IUserGroupHasId | undefined>(parentUserGroup);
   /*
   /*
    * Function
    * Function
    */
    */
@@ -44,10 +43,12 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
     }
     }
   }, [selectedParent, setSelectedParent]);
   }, [selectedParent, setSelectedParent]);
 
 
+  const isSelectableParentUserGroups = selectableParentUserGroups != null && selectableParentUserGroups.length > 0;
+
   return (
   return (
     <form onSubmit={(e) => {
     <form onSubmit={(e) => {
       e.preventDefault();
       e.preventDefault();
-      onSubmit?.(props.userGroup, {
+      onSubmit(props.userGroup, {
         name: currentName,
         name: currentName,
         description: currentDescription,
         description: currentDescription,
         parent: selectedParent,
         parent: selectedParent,
@@ -103,14 +104,14 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
               id="dropdownMenuButton"
               id="dropdownMenuButton"
               data-toggle="dropdown"
               data-toggle="dropdown"
               className={`
               className={`
-                btn btn-outline-secondary dropdown-toggle mb-3 ${selectableParentUserGroups != null && selectableParentUserGroups.length > 0 ? '' : 'disabled'}
+                btn btn-outline-secondary dropdown-toggle mb-3 ${isSelectableParentUserGroups ? '' : 'disabled'}
               `}
               `}
             >
             >
               {selectedParent?.name ?? t('user_group_management.select_parent_group')}
               {selectedParent?.name ?? t('user_group_management.select_parent_group')}
             </button>
             </button>
             <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
             <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
               {
               {
-                (selectableParentUserGroups != null && selectableParentUserGroups.length > 0) && (
+                isSelectableParentUserGroups && (
                   <>
                   <>
                     {
                     {
                       selectableParentUserGroups.map(userGroup => (
                       selectableParentUserGroups.map(userGroup => (

+ 9 - 2
apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -21,9 +21,12 @@ import {
   useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList, useSWRxUserGroup,
   useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList, useSWRxUserGroup,
   useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups, useSWRxUserGroupRelations,
   useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups, useSWRxUserGroupRelations,
 } from '~/stores/user-group';
 } from '~/stores/user-group';
+import loggerFactory from '~/utils/logger';
 
 
 import styles from './UserGroupDetailPage.module.scss';
 import styles from './UserGroupDetailPage.module.scss';
 
 
+const logger = loggerFactory('growi:services:AdminCustomizeContainer');
+
 const UserGroupPageList = dynamic(() => import('./UserGroupPageList'), { ssr: false });
 const UserGroupPageList = dynamic(() => import('./UserGroupPageList'), { ssr: false });
 const UserGroupUserTable = dynamic(() => import('./UserGroupUserTable'), { ssr: false });
 const UserGroupUserTable = dynamic(() => import('./UserGroupUserTable'), { ssr: false });
 
 
@@ -48,6 +51,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
   const { userGroupId: currentUserGroupId } = props;
   const { userGroupId: currentUserGroupId } = props;
 
 
   const { data: currentUserGroup } = useSWRxUserGroup(currentUserGroupId);
   const { data: currentUserGroup } = useSWRxUserGroup(currentUserGroupId);
+
   const [searchType, setSearchType] = useState<SearchType>(SearchTypes.PARTIAL);
   const [searchType, setSearchType] = useState<SearchType>(SearchTypes.PARTIAL);
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
   const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
   const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
@@ -91,6 +95,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
 
 
   const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModal();
   const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModal();
 
 
+  const parentUserGroup = selectableParentUserGroups?.find(selectableParentUserGroup => selectableParentUserGroup._id === currentUserGroup?.parent);
   /*
   /*
    * Function
    * Function
    */
    */
@@ -135,9 +140,10 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
     [t, updateUserGroup],
     [t, updateUserGroup],
   );
   );
 
 
-  const onClickSubmitForm = useCallback(async(targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>): Promise<void> => {
-    if (typeof userGroupData?.parent === 'string') {
+  const onClickSubmitForm = useCallback(async(targetGroup: IUserGroupHasId, userGroupData: IUserGroupHasId) => {
+    if (typeof userGroupData.parent === 'string') {
       toastError(t('Something went wrong. Please try again.'));
       toastError(t('Something went wrong. Please try again.'));
+      logger.error('Something went wrong.');
       return;
       return;
     }
     }
 
 
@@ -356,6 +362,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
       <div className="mt-4 form-box">
       <div className="mt-4 form-box">
         <UserGroupForm
         <UserGroupForm
           userGroup={currentUserGroup}
           userGroup={currentUserGroup}
+          parentUserGroup={parentUserGroup}
           selectableParentUserGroups={selectableParentUserGroups}
           selectableParentUserGroups={selectableParentUserGroups}
           submitButtonLabel={t('Update')}
           submitButtonLabel={t('Update')}
           onSubmit={onClickSubmitForm}
           onSubmit={onClickSubmitForm}

+ 103 - 10
apps/app/src/components/Admin/Users/PasswordResetModal.jsx

@@ -2,13 +2,15 @@ import React from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+import { CopyToClipboard } from 'react-copy-to-clipboard';
 import {
 import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
+  Modal, ModalHeader, ModalBody, ModalFooter, Tooltip,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
-
+import { useIsMailerSetup } from '~/stores/context';
 
 
 class PasswordResetModal extends React.Component {
 class PasswordResetModal extends React.Component {
 
 
@@ -16,11 +18,15 @@ class PasswordResetModal extends React.Component {
     super(props);
     super(props);
 
 
     this.state = {
     this.state = {
-      temporaryPassword: [],
+      temporaryPassword: '',
       isPasswordResetDone: false,
       isPasswordResetDone: false,
+      isEmailSent: false,
+      isEmailSending: false,
+      showTooltip: false,
     };
     };
 
 
     this.resetPassword = this.resetPassword.bind(this);
     this.resetPassword = this.resetPassword.bind(this);
+    this.onClickSendNewPasswordButton = this.onClickSendNewPasswordButton.bind(this);
   }
   }
 
 
   async resetPassword() {
   async resetPassword() {
@@ -35,6 +41,44 @@ class PasswordResetModal extends React.Component {
     }
     }
   }
   }
 
 
+  renderButtons() {
+    const { t, isMailerSetup } = this.props;
+    const { isEmailSent, isEmailSending } = this.state;
+
+    return (
+      <>
+        <button type="submit" className={`btn ${isEmailSent ? 'btn-secondary' : 'btn-primary'}`}
+          onClick={this.onClickSendNewPasswordButton} disabled={!isMailerSetup || isEmailSending || isEmailSent}>
+          {isEmailSending && <i className='fa fa-spinner fa-pulse mx-2' />}
+          {!isEmailSending && (isEmailSent ? t('commons:Done') : t('commons:Send'))}
+        </button>
+        <button type="submit" className="btn btn-danger" onClick={this.props.onClose}>
+          {t('commons:Close')}
+        </button>
+      </>
+    );
+  }
+
+  renderAddress() {
+    const { t, isMailerSetup, userForPasswordResetModal } = this.props;
+
+    return (
+      <div className="d-flex col text-left ml-1 pl-0">
+        {!isMailerSetup ? (
+          <label className="form-text text-muted" dangerouslySetInnerHTML={{ __html: t('admin:mailer_setup_required') }} />
+        ) : (
+          <>
+            <p className="mr-2">To:</p>
+            <div>
+              <p className="mb-0">{userForPasswordResetModal.username}</p>
+              <p className="mb-0">{userForPasswordResetModal.email}</p>
+            </div>
+          </>
+        )}
+      </div>
+    );
+  }
+
   renderModalBodyBeforeReset() {
   renderModalBodyBeforeReset() {
     const { t, userForPasswordResetModal } = this.props;
     const { t, userForPasswordResetModal } = this.props;
 
 
@@ -53,6 +97,11 @@ class PasswordResetModal extends React.Component {
 
 
   returnModalBodyAfterReset() {
   returnModalBodyAfterReset() {
     const { t, userForPasswordResetModal } = this.props;
     const { t, userForPasswordResetModal } = this.props;
+    const { temporaryPassword, showPassword, showTooltip } = this.state;
+
+    const maskedPassword = showPassword
+      ? temporaryPassword
+      : '•'.repeat(temporaryPassword.length);
 
 
     return (
     return (
       <>
       <>
@@ -61,7 +110,28 @@ class PasswordResetModal extends React.Component {
           {t('user_management.reset_password_modal.target_user')}: <code>{userForPasswordResetModal.email}</code>
           {t('user_management.reset_password_modal.target_user')}: <code>{userForPasswordResetModal.email}</code>
         </p>
         </p>
         <p>
         <p>
-          {t('user_management.reset_password_modal.new_password')}: <code>{this.state.temporaryPassword}</code>
+          {t('user_management.reset_password_modal.new_password')}:{' '}
+          <code>
+            <span
+              onMouseEnter={() => this.setState({ showPassword: true })}
+              onMouseLeave={() => this.setState({ showPassword: false })}
+            >
+              {showPassword ? temporaryPassword : maskedPassword}
+            </span>
+          </code>
+          <CopyToClipboard text={ temporaryPassword } onCopy={() => this.setState({ showTooltip: true })}>
+            <button id="copy-tooltip" type="button" className="btn btn-outline-secondary border-0">
+              <i className="fa fa-clone" aria-hidden="true"></i>
+            </button>
+          </CopyToClipboard>
+          <Tooltip
+            placement="right"
+            isOpen={showTooltip}
+            target="copy-tooltip"
+            toggle={() => this.setState({ showTooltip: false })}
+          >
+            {t('Copied!')}
+          </Tooltip>
         </p>
         </p>
       </>
       </>
     );
     );
@@ -77,15 +147,35 @@ class PasswordResetModal extends React.Component {
   }
   }
 
 
   returnModalFooterAfterReset() {
   returnModalFooterAfterReset() {
-    const { t } = this.props;
-
     return (
     return (
-      <button type="submit" className="btn btn-primary" onClick={this.props.onClose}>
-        {t('Close')}
-      </button>
+      <>
+        {this.renderAddress()}
+        {this.renderButtons()}
+      </>
     );
     );
   }
   }
 
 
+  async onClickSendNewPasswordButton() {
+
+    const {
+      userForPasswordResetModal,
+    } = this.props;
+
+    this.setState({ isEmailSending: true });
+
+    try {
+      await apiv3Put('/users/reset-password-email', { id: userForPasswordResetModal._id, newPassword: this.state.temporaryPassword });
+      this.setState({ isEmailSent: true });
+    }
+    catch (err) {
+      this.setState({ isEmailSent: false });
+      toastError(err);
+    }
+    finally {
+      this.setState({ isEmailSending: false });
+    }
+  }
+
 
 
   render() {
   render() {
     const { t } = this.props;
     const { t } = this.props;
@@ -109,7 +199,8 @@ class PasswordResetModal extends React.Component {
 
 
 const PasswordResetModalWrapperFC = (props) => {
 const PasswordResetModalWrapperFC = (props) => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
-  return <PasswordResetModal t={t} {...props} />;
+  const { data: isMailerSetup } = useIsMailerSetup();
+  return <PasswordResetModal t={t} isMailerSetup={isMailerSetup ?? false} {...props} />;
 };
 };
 
 
 /**
 /**
@@ -122,6 +213,8 @@ PasswordResetModal.propTypes = {
   isOpen: PropTypes.bool.isRequired,
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func.isRequired,
   onClose: PropTypes.func.isRequired,
   userForPasswordResetModal: PropTypes.object,
   userForPasswordResetModal: PropTypes.object,
+  onSuccessfullySentNewPasswordEmail: PropTypes.func.isRequired,
+  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
 
 
 };
 };
 
 

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

@@ -10,6 +10,7 @@ import Sidebar from '../Sidebar';
 import { RawLayout } from './RawLayout';
 import { RawLayout } from './RawLayout';
 
 
 const AlertSiteUrlUndefined = dynamic(() => import('../AlertSiteUrlUndefined').then(mod => mod.AlertSiteUrlUndefined), { ssr: false });
 const AlertSiteUrlUndefined = dynamic(() => import('../AlertSiteUrlUndefined').then(mod => mod.AlertSiteUrlUndefined), { ssr: false });
+const DeleteAttachmentModal = dynamic(() => import('../PageAttachment/DeleteAttachmentModal').then(mod => mod.DeleteAttachmentModal), { ssr: false });
 const HotkeysManager = dynamic(() => import('../Hotkeys/HotkeysManager'), { ssr: false });
 const HotkeysManager = dynamic(() => import('../Hotkeys/HotkeysManager'), { ssr: false });
 // const PageCreateModal = dynamic(() => import('../client/js/components/PageCreateModal'), { 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 GrowiNavbarBottom = dynamic(() => import('../Navbar/GrowiNavbarBottom').then(mod => mod.GrowiNavbarBottom), { ssr: false });
@@ -56,6 +57,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
         <PageDeleteModal />
         <PageDeleteModal />
         <PageRenameModal />
         <PageRenameModal />
         <PageAccessoriesModal />
         <PageAccessoriesModal />
+        <DeleteAttachmentModal />
         <DeleteBookmarkFolderModal />
         <DeleteBookmarkFolderModal />
       </DndProvider>
       </DndProvider>
 
 

+ 11 - 2
apps/app/src/components/Me/BasicInfoSettings.tsx

@@ -24,8 +24,17 @@ export const BasicInfoSettings = (): JSX.Element => {
       sync();
       sync();
       toastSuccess(t('toaster.update_successed', { target: t('Basic Info'), ns: 'commons' }));
       toastSuccess(t('toaster.update_successed', { target: t('Basic Info'), ns: 'commons' }));
     }
     }
-    catch (err) {
-      toastError(err);
+    catch (errs) {
+      const err = errs[0];
+      const message = err.message;
+      const code = err.code;
+
+      if (code === 'email-is-already-in-use') {
+        toastError(t('alert.email_is_already_in_use', { ns: 'commons' }));
+      }
+      else {
+        toastError(message);
+      }
     }
     }
   };
   };
 
 

+ 10 - 11
apps/app/src/components/Page/TagEditModal.jsx → apps/app/src/components/Page/TagEditModal.tsx

@@ -1,18 +1,24 @@
 import React, { useState, useEffect } from 'react';
 import React, { useState, useEffect } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
 import {
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 import TagsInput from './TagsInput';
 import TagsInput from './TagsInput';
 
 
-function TagEditModal(props) {
-  const [tags, setTags] = useState([]);
+type Props = {
+  tags: string[],
+  isOpen: boolean,
+  onClose?: () => void,
+  onTagsUpdated?: (tags: string[]) => Promise<void> | void,
+};
+
+function TagEditModal(props: Props): JSX.Element {
+  const [tags, setTags] = useState<string[]>([]);
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  function onTagsUpdatedByTagsInput(tags) {
+  function onTagsUpdatedByTagsInput(tags: string[]) {
     setTags(tags);
     setTags(tags);
   }
   }
 
 
@@ -54,11 +60,4 @@ function TagEditModal(props) {
 
 
 }
 }
 
 
-TagEditModal.propTypes = {
-  tags: PropTypes.array,
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func,
-  onTagsUpdated: PropTypes.func,
-};
-
 export default TagEditModal;
 export default TagEditModal;

+ 7 - 57
apps/app/src/components/PageAttachment.tsx

@@ -6,9 +6,9 @@ import { IAttachmentHasId } from '@growi/core';
 
 
 import { useSWRxAttachments } from '~/stores/attachment';
 import { useSWRxAttachments } from '~/stores/attachment';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
+import { useDeleteAttachmentModal } from '~/stores/modal';
 import { useSWRxCurrentPage, useCurrentPageId } from '~/stores/page';
 import { useSWRxCurrentPage, useCurrentPageId } from '~/stores/page';
 
 
-import { DeleteAttachmentModal } from './PageAttachment/DeleteAttachmentModal';
 import { PageAttachmentList } from './PageAttachment/PageAttachmentList';
 import { PageAttachmentList } from './PageAttachment/PageAttachmentList';
 import PaginationWrapper from './PaginationWrapper';
 import PaginationWrapper from './PaginationWrapper';
 
 
@@ -19,9 +19,6 @@ const checkIfFileInUse = (markdown: string, attachment): boolean => {
 
 
 const PageAttachment = (): JSX.Element => {
 const PageAttachment = (): JSX.Element => {
 
 
-  const { data: currentPage } = useSWRxCurrentPage();
-  const markdown = currentPage?.revision.body;
-
   // Static SWRs
   // Static SWRs
   const { data: pageId } = useCurrentPageId();
   const { data: pageId } = useCurrentPageId();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
@@ -31,12 +28,12 @@ const PageAttachment = (): JSX.Element => {
 
 
   // States
   // States
   const [pageNumber, setPageNumber] = useState(1);
   const [pageNumber, setPageNumber] = useState(1);
-  const [attachmentToDelete, setAttachmentToDelete] = useState<(IAttachmentHasId) | null>(null);
-  const [deleting, setDeleting] = useState(false);
-  const [deleteError, setDeleteError] = useState('');
 
 
   // SWRs
   // SWRs
   const { data: dataAttachments, remove } = useSWRxAttachments(pageId, pageNumber);
   const { data: dataAttachments, remove } = useSWRxAttachments(pageId, pageNumber);
+  const { open: openDeleteAttachmentModal } = useDeleteAttachmentModal();
+  const { data: currentPage } = useSWRxCurrentPage();
+  const markdown = currentPage?.revision.body;
 
 
   // Custom hooks
   // Custom hooks
   const inUseAttachmentsMap: { [id: string]: boolean } | undefined = useMemo(() => {
   const inUseAttachmentsMap: { [id: string]: boolean } | undefined = useMemo(() => {
@@ -57,29 +54,9 @@ const PageAttachment = (): JSX.Element => {
     setPageNumber(newPageNumber);
     setPageNumber(newPageNumber);
   }, []);
   }, []);
 
 
-  const onAttachmentDeleteClicked = useCallback((attachment) => {
-    setAttachmentToDelete(attachment);
-  }, []);
-
-  const onAttachmentDeleteClickedConfirmHandler = useCallback(async(attachment: IAttachmentHasId) => {
-    setDeleting(true);
-
-    try {
-      await remove({ attachment_id: attachment._id });
-
-      setAttachmentToDelete(null);
-      setDeleting(false);
-    }
-    catch {
-      setDeleteError('Something went wrong.');
-      setDeleting(false);
-    }
-  }, [remove]);
-
-  const onToggleHandler = useCallback(() => {
-    setAttachmentToDelete(null);
-    setDeleteError('');
-  }, []);
+  const onAttachmentDeleteClicked = useCallback((attachment: IAttachmentHasId) => {
+    openDeleteAttachmentModal(attachment, remove);
+  }, [openDeleteAttachmentModal, remove]);
 
 
   // Renderers
   // Renderers
   const renderPageAttachmentList = useCallback(() => {
   const renderPageAttachmentList = useCallback(() => {
@@ -101,30 +78,6 @@ const PageAttachment = (): JSX.Element => {
     );
     );
   }, [dataAttachments, inUseAttachmentsMap, isPageAttachmentDisabled, onAttachmentDeleteClicked]);
   }, [dataAttachments, inUseAttachmentsMap, isPageAttachmentDisabled, onAttachmentDeleteClicked]);
 
 
-  const renderDeleteAttachmentModal = useCallback(() => {
-    if (isPageAttachmentDisabled) {
-      return <></>;
-    }
-
-    if (dataAttachments == null || dataAttachments.attachments.length === 0 || attachmentToDelete == null) {
-      return <></>;
-    }
-
-    const isOpen = attachmentToDelete != null;
-
-    return (
-      <DeleteAttachmentModal
-        isOpen={isOpen}
-        toggle={onToggleHandler}
-        attachmentToDelete={attachmentToDelete}
-        deleting={deleting}
-        deleteError={deleteError}
-        onAttachmentDeleteClickedConfirm={onAttachmentDeleteClickedConfirmHandler}
-      />
-    );
-  // eslint-disable-next-line max-len
-  }, [attachmentToDelete, dataAttachments, deleteError, deleting, isPageAttachmentDisabled, onAttachmentDeleteClickedConfirmHandler, onToggleHandler]);
-
   const renderPaginationWrapper = useCallback(() => {
   const renderPaginationWrapper = useCallback(() => {
     if (dataAttachments == null || dataAttachments.attachments.length === 0) {
     if (dataAttachments == null || dataAttachments.attachments.length === 0) {
       return <></>;
       return <></>;
@@ -144,9 +97,6 @@ const PageAttachment = (): JSX.Element => {
   return (
   return (
     <div data-testid="page-attachment">
     <div data-testid="page-attachment">
       {renderPageAttachmentList()}
       {renderPageAttachmentList()}
-
-      {renderDeleteAttachmentModal()}
-
       {renderPaginationWrapper()}
       {renderPaginationWrapper()}
     </div>
     </div>
   );
   );

+ 65 - 48
apps/app/src/components/PageAttachment/DeleteAttachmentModal.tsx

@@ -1,79 +1,97 @@
-/* eslint-disable react/prop-types */
-import React, { useCallback } from 'react';
+import React, {
+  useCallback, useMemo, useState,
+} from 'react';
 
 
-import { IAttachmentHasId } from '@growi/core';
+import type { IUser } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components/User/UserPicture';
 import { UserPicture } from '@growi/ui/dist/components/User/UserPicture';
+import { useTranslation } from 'next-i18next';
 import {
 import {
-  Button,
-  Modal, ModalHeader, ModalBody, ModalFooter,
+  Button, Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useDeleteAttachmentModal } from '~/stores/modal';
+import loggerFactory from '~/utils/logger';
+
 import { Username } from '../User/Username';
 import { Username } from '../User/Username';
 
 
 import styles from './DeleteAttachmentModal.module.scss';
 import styles from './DeleteAttachmentModal.module.scss';
 
 
+const logger = loggerFactory('growi:attachmentDelete');
+
+const iconByFormat = (format: string): string => {
+  return format.match(/image\/.+/i) ? 'icon-picture' : 'icon-doc';
+};
 
 
-function iconNameByFormat(format: string): string {
-  if (format.match(/image\/.+/i)) {
-    return 'icon-picture';
-  }
+export const DeleteAttachmentModal: React.FC = () => {
+  const [deleting, setDeleting] = useState<boolean>(false);
+  const [deleteError, setDeleteError] = useState<string>('');
 
 
-  return 'icon-doc';
-}
+  const { t } = useTranslation();
+  const { data: deleteAttachmentModal, close: closeDeleteAttachmentModal } = useDeleteAttachmentModal();
+  const isOpen = deleteAttachmentModal?.isOpened;
+  const attachment = deleteAttachmentModal?.attachment;
+  const remove = deleteAttachmentModal?.remove;
 
 
+  const toggleHandler = useCallback(() => {
+    closeDeleteAttachmentModal();
+    setDeleting(false);
+    setDeleteError('');
+  }, [closeDeleteAttachmentModal]);
 
 
-type Props = {
-  isOpen: boolean,
-  toggle: () => void,
-  attachmentToDelete: IAttachmentHasId | null,
-  deleting: boolean,
-  deleteError: string,
-  onAttachmentDeleteClickedConfirm?: (attachment: IAttachmentHasId) => Promise<void>,
-}
+  const onClickDeleteButtonHandler = useCallback(async() => {
+    if (remove == null || attachment == null) {
+      return;
+    }
 
 
-export const DeleteAttachmentModal = (props: Props): JSX.Element => {
+    setDeleting(true);
 
 
-  const {
-    isOpen, toggle,
-    attachmentToDelete, deleting, deleteError,
-    onAttachmentDeleteClickedConfirm,
-  } = props;
+    try {
+      await remove({ attachment_id: attachment._id });
+      setDeleting(false);
+      closeDeleteAttachmentModal();
+      toastSuccess(`Delete ${attachment.originalName}`);
+    }
+    catch (err) {
+      setDeleting(false);
+      setDeleteError('Attachment could not be deleted.');
+      toastError(err);
+      logger.error(err);
+    }
+  }, [attachment, closeDeleteAttachmentModal, remove]);
 
 
-  const onDeleteConfirm = useCallback(() => {
-    if (attachmentToDelete == null || onAttachmentDeleteClickedConfirm == null) {
+  const attachmentFileFormat = useMemo(() => {
+    if (attachment == null) {
       return;
       return;
     }
     }
-    onAttachmentDeleteClickedConfirm(attachmentToDelete);
-  }, [attachmentToDelete, onAttachmentDeleteClickedConfirm]);
 
 
-  const renderByFileFormat = useCallback((attachment) => {
     const content = (attachment.fileFormat.match(/image\/.+/i))
     const content = (attachment.fileFormat.match(/image\/.+/i))
       // eslint-disable-next-line @next/next/no-img-element
       // eslint-disable-next-line @next/next/no-img-element
       ? <img src={attachment.filePathProxied} alt="deleting image" />
       ? <img src={attachment.filePathProxied} alt="deleting image" />
       : '';
       : '';
 
 
-
     return (
     return (
       <div className="attachment-delete-image">
       <div className="attachment-delete-image">
         <p>
         <p>
-          <i className={iconNameByFormat(attachment.fileFormat)}></i> {attachment.originalName}
+          <i className={iconByFormat(attachment.fileFormat)}></i> {attachment.originalName}
         </p>
         </p>
         <p>
         <p>
-          uploaded by <UserPicture user={attachment.creator} size="sm"></UserPicture> <Username user={attachment.creator}></Username>
+          uploaded by <UserPicture user={attachment.creator} size="sm"></UserPicture> <Username user={attachment.creator as IUser}></Username>
         </p>
         </p>
         {content}
         {content}
       </div>
       </div>
     );
     );
-  }, []);
-
-  let deletingIndicator = <></>;
-  if (deleting) {
-    deletingIndicator = <div className="speeding-wheel-sm"></div>;
-  }
-  if (deleteError) {
-    deletingIndicator = <span>{deleteError}</span>;
-  }
+  }, [attachment]);
 
 
+  const deletingIndicator = useMemo(() => {
+    if (deleting) {
+      return <div className="speeding-wheel-sm"></div>;
+    }
+    if (deleteError) {
+      return <span>{deleteError}</span>;
+    }
+    return <></>;
+  }, [deleting, deleteError]);
 
 
   return (
   return (
     <Modal
     <Modal
@@ -83,11 +101,11 @@ export const DeleteAttachmentModal = (props: Props): JSX.Element => {
       aria-labelledby="contained-modal-title-lg"
       aria-labelledby="contained-modal-title-lg"
       fade={false}
       fade={false}
     >
     >
-      <ModalHeader tag="h4" toggle={toggle} className="bg-danger text-light">
-        <span id="contained-modal-title-lg">Delete attachment?</span>
+      <ModalHeader tag="h4" toggle={toggleHandler} className="bg-danger text-light">
+        <span id="contained-modal-title-lg">{t('delete_attachment_modal.confirm_delete_attachment')}</span>
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
-        {renderByFileFormat(attachmentToDelete)}
+        {attachmentFileFormat}
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
         <div className="mr-3 d-inline-block">
         <div className="mr-3 d-inline-block">
@@ -95,12 +113,11 @@ export const DeleteAttachmentModal = (props: Props): JSX.Element => {
         </div>
         </div>
         <Button
         <Button
           color="danger"
           color="danger"
-          onClick={onDeleteConfirm}
+          onClick={onClickDeleteButtonHandler}
           disabled={deleting}
           disabled={deleting}
-        >Delete!
+        >{t('commons:Delete')}
         </Button>
         </Button>
       </ModalFooter>
       </ModalFooter>
     </Modal>
     </Modal>
   );
   );
-
 };
 };

+ 1 - 1
apps/app/src/components/PageAttachment/PageAttachmentList.tsx

@@ -7,7 +7,7 @@ import { useTranslation } from 'next-i18next';
 type Props = {
 type Props = {
   attachments: (IAttachmentHasId)[],
   attachments: (IAttachmentHasId)[],
   inUse: { [id: string]: boolean },
   inUse: { [id: string]: boolean },
-  onAttachmentDeleteClicked?: (attachment: IAttachmentHasId) => void,
+  onAttachmentDeleteClicked: (attachment: IAttachmentHasId) => void,
   isUserLoggedIn?: boolean,
   isUserLoggedIn?: boolean,
 }
 }
 
 

+ 31 - 31
apps/app/src/components/PageEditor/HandsontableModal.tsx

@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState } from 'react';
 
 
 import { HotTable } from '@handsontable/react';
 import { HotTable } from '@handsontable/react';
 import Handsontable from 'handsontable';
 import Handsontable from 'handsontable';
@@ -7,7 +7,6 @@ import {
   Collapse,
   Collapse,
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
-import { debounce } from 'throttle-debounce';
 
 
 import MarkdownTable from '~/client/models/MarkdownTable';
 import MarkdownTable from '~/client/models/MarkdownTable';
 import mtu from '~/components/PageEditor/MarkdownTableUtil';
 import mtu from '~/components/PageEditor/MarkdownTableUtil';
@@ -19,6 +18,7 @@ import { MarkdownTableDataImportForm } from './MarkdownTableDataImportForm';
 
 
 import styles from './HandsontableModal.module.scss';
 import styles from './HandsontableModal.module.scss';
 import 'handsontable/dist/handsontable.full.min.css';
 import 'handsontable/dist/handsontable.full.min.css';
+import { debounce } from 'throttle-debounce';
 
 
 const DEFAULT_HOT_HEIGHT = 300;
 const DEFAULT_HOT_HEIGHT = 300;
 const MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING = {
 const MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING = {
@@ -88,13 +88,37 @@ export const HandsontableModal = (): JSX.Element => {
   const [markdownTable, setMarkdownTable] = useState<MarkdownTable>(defaultMarkdownTable);
   const [markdownTable, setMarkdownTable] = useState<MarkdownTable>(defaultMarkdownTable);
   const [markdownTableOnInit, setMarkdownTableOnInit] = useState<MarkdownTable>(defaultMarkdownTable);
   const [markdownTableOnInit, setMarkdownTableOnInit] = useState<MarkdownTable>(defaultMarkdownTable);
   const [handsontableHeight, setHandsontableHeight] = useState<number>(DEFAULT_HOT_HEIGHT);
   const [handsontableHeight, setHandsontableHeight] = useState<number>(DEFAULT_HOT_HEIGHT);
+  const [handsontableWidth, setHandsontableWidth] = useState<number>(0);
 
 
-  useEffect(() => {
+  const handleWindowExpandedChange = () => {
+    if (hotTableContainer != null) {
+      // Get the width and height of hotTableContainer
+      const { width, height } = hotTableContainer.getBoundingClientRect();
+      setHandsontableWidth(width);
+      setHandsontableHeight(height);
+    }
+  };
+
+  const debouncedHandleWindowExpandedChange = debounce(100, handleWindowExpandedChange);
+
+  const handleModalOpen = () => {
     const initTableInstance = table == null ? defaultMarkdownTable : table.clone();
     const initTableInstance = table == null ? defaultMarkdownTable : table.clone();
     setMarkdownTable(table ?? defaultMarkdownTable);
     setMarkdownTable(table ?? defaultMarkdownTable);
     setMarkdownTableOnInit(initTableInstance);
     setMarkdownTableOnInit(initTableInstance);
-  // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [isOpened]);
+    debouncedHandleWindowExpandedChange();
+  };
+
+  const expandWindow = () => {
+    setIsWindowExpanded(true);
+    debouncedHandleWindowExpandedChange();
+  };
+
+  const contractWindow = () => {
+    setIsWindowExpanded(false);
+    // Set the height to the default value
+    setHandsontableHeight(DEFAULT_HOT_HEIGHT);
+    debouncedHandleWindowExpandedChange();
+  };
 
 
   const markdownTableOption = {
   const markdownTableOption = {
     get latest() {
     get latest() {
@@ -363,32 +387,6 @@ export const HandsontableModal = (): JSX.Element => {
     toggleDataImportArea();
     toggleDataImportArea();
   };
   };
 
 
-  /**
-   * Expand the height of the Handsontable
-   *  by updating 'handsontableHeight' state
-   *  according to the height of this.refs.hotTableContainer
-   */
-  const expandHotTableHeight = () => {
-    if (isWindowExpanded && hotTableContainer != null) {
-      const height = hotTableContainer.getBoundingClientRect().height;
-      setHandsontableHeight(height);
-    }
-  };
-
-  const expandWindow = () => {
-    setIsWindowExpanded(true);
-
-    // create debounced method for expanding HotTable
-    // invoke updateHotTableHeight method with delay
-    // cz. Resizing this.refs.hotTableContainer is completed after a little delay after 'isWindowExpanded' set with 'true'
-    debounce(100, expandHotTableHeight);
-  };
-
-  const contractWindow = () => {
-    setIsWindowExpanded(false);
-    setHandsontableHeight(DEFAULT_HOT_HEIGHT);
-  };
-
   const createCustomizedContextMenu = () => {
   const createCustomizedContextMenu = () => {
     return {
     return {
       items: {
       items: {
@@ -453,6 +451,7 @@ export const HandsontableModal = (): JSX.Element => {
       size="lg"
       size="lg"
       wrapClassName={`${styles['grw-handsontable']}`}
       wrapClassName={`${styles['grw-handsontable']}`}
       className={`handsontable-modal ${isWindowExpanded && 'grw-modal-expanded'}`}
       className={`handsontable-modal ${isWindowExpanded && 'grw-modal-expanded'}`}
+      onOpened={handleModalOpen}
     >
     >
       <ModalHeader tag="h4" toggle={cancel} close={closeButton} className="bg-primary text-light">
       <ModalHeader tag="h4" toggle={cancel} close={closeButton} className="bg-primary text-light">
         {t('handsontable_modal.title')}
         {t('handsontable_modal.title')}
@@ -493,6 +492,7 @@ export const HandsontableModal = (): JSX.Element => {
             data={markdownTable.table}
             data={markdownTable.table}
             settings={handsontableSettings as Handsontable.DefaultSettings}
             settings={handsontableSettings as Handsontable.DefaultSettings}
             height={handsontableHeight}
             height={handsontableHeight}
+            width={handsontableWidth}
             afterLoadData={afterLoadDataHandler}
             afterLoadData={afterLoadDataHandler}
             modifyColWidth={modifyColWidthHandler}
             modifyColWidth={modifyColWidthHandler}
             beforeColumnMove={beforeColumnMoveHandler}
             beforeColumnMove={beforeColumnMoveHandler}

+ 0 - 17
apps/app/src/components/ReactMarkdownComponents/NextLink.tsx

@@ -22,10 +22,6 @@ const isExternalLink = (href: string, siteUrl: string | undefined): boolean => {
   }
   }
 };
 };
 
 
-const isAttached = (href: string): boolean => {
-  return href.startsWith('/attachment/');
-};
-
 type Props = Omit<LinkProps, 'href'> & {
 type Props = Omit<LinkProps, 'href'> & {
   children: React.ReactNode,
   children: React.ReactNode,
   id?: string,
   id?: string,
@@ -64,19 +60,6 @@ export const NextLink = (props: Props): JSX.Element => {
     );
     );
   }
   }
 
 
-  // when href is an attachment file
-  if (isAttached(href)) {
-    const dlhref = href.replace('/attachment/', '/download/');
-    return (
-      <span>
-        <a id={id} href={href} className={className} target="_blank" rel="noopener noreferrer" {...dataAttributes}>
-          {children}
-        </a>&nbsp;
-        <a href={dlhref} className="attachment-download"><i className='icon-cloud-download'></i></a>
-      </span>
-    );
-  }
-
   return (
   return (
     <Link {...rest} href={href} prefetch={false} legacyBehavior>
     <Link {...rest} href={href} prefetch={false} legacyBehavior>
       <a href={href} className={className} {...dataAttributes}>{children}</a>
       <a href={href} className={className} {...dataAttributes}>{children}</a>

+ 7 - 0
apps/app/src/components/ReactMarkdownComponents/RichAttachment.module.scss

@@ -0,0 +1,7 @@
+.attachment :global {
+  .attachment-icon {
+    flex-shrink: 0;
+    width: 35px;
+    height: 35px;
+  }
+}

+ 84 - 0
apps/app/src/components/ReactMarkdownComponents/RichAttachment.tsx

@@ -0,0 +1,84 @@
+import React, { useCallback } from 'react';
+
+import { UserPicture } from '@growi/ui/dist/components/User/UserPicture';
+import { useTranslation } from 'next-i18next';
+import prettyBytes from 'pretty-bytes';
+
+import { useSWRxAttachment } from '~/stores/attachment';
+import { useDeleteAttachmentModal } from '~/stores/modal';
+
+import styles from './RichAttachment.module.scss';
+
+export const RichAttachment: React.FC<{
+  attachmentId: string,
+  url: string,
+  attachmentName: string
+}> = React.memo(({ attachmentId, url, attachmentName }) => {
+  const { t } = useTranslation();
+  const { data: attachment, remove } = useSWRxAttachment(attachmentId);
+  const { open: openDeleteAttachmentModal } = useDeleteAttachmentModal();
+
+  const onClickTrashButtonHandler = useCallback(() => {
+    if (attachment == null) {
+      return;
+    }
+    openDeleteAttachmentModal(attachment, remove);
+  }, [attachment, openDeleteAttachmentModal, remove]);
+
+  if (attachment == null) {
+    return <span className='text-muted'>{t('rich_attachment.attachment_not_be_found')}</span>;
+  }
+
+  const {
+    filePathProxied,
+    originalName,
+    downloadPathProxied,
+    creator,
+    createdAt,
+    fileSize,
+  } = attachment;
+
+  // Guard here because attachment properties might be deleted in turn when an attachment is removed
+  if (filePathProxied == null
+    || originalName == null
+    || downloadPathProxied == null
+    || creator == null
+    || createdAt == null
+    || fileSize == null
+  ) {
+    return <span className='text-muted'>{t('rich_attachment.attachment_not_be_found')}</span>;
+  }
+
+  return (
+    <div className={`${styles.attachment} d-inline-block`}>
+      <div className="my-2 p-2 card">
+        <div className="p-1 card-body d-flex align-items-center">
+          <div className='mr-2 px-0 d-flex align-items-center justify-content-center'>
+            <img src='/images/icons/editor/attachment.svg' className="attachment-icon" alt='attachment icon'/>
+          </div>
+          <div className='pl-0'>
+            <div className='d-inline-block'>
+              <a target="_blank" rel="noopener noreferrer" href={filePathProxied}>
+                {attachmentName || originalName}
+              </a>
+              <a className="ml-2 attachment-download" href={downloadPathProxied}>
+                <i className="icon-cloud-download"/>
+              </a>
+              <a className="ml-2 text-danger attachment-delete" onClick={onClickTrashButtonHandler}>
+                <i className="icon-trash"/>
+              </a>
+            </div>
+            <div className='d-flex align-items-center'>
+              <UserPicture user={creator} size="sm"/>
+              <span className='ml-2 text-muted'>
+                {new Date(createdAt).toLocaleString('en-US')}
+              </span>
+              <span className='ml-2 pl-2 border-left text-muted'>{prettyBytes(fileSize)}</span>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+});
+RichAttachment.displayName = 'RichAttachment';

+ 1 - 1
apps/app/src/components/ReactMarkdownComponents/TableWithEditButton.tsx

@@ -46,7 +46,7 @@ export const TableWithEditButton = React.memo((props: TableWithEditButtonProps):
           <i className="icon-note"></i>
           <i className="icon-note"></i>
         </button>
         </button>
       )}
       )}
-      <table className={`${className}`}>
+      <table className={className}>
         {children}
         {children}
       </table>
       </table>
     </div>
     </div>

+ 20 - 4
apps/app/src/components/SearchPage.tsx

@@ -92,7 +92,10 @@ SearchResultListHead.displayName = 'SearchResultListHead';
 export const SearchPage = (): JSX.Element => {
 export const SearchPage = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { data: showPageLimitationL } = useShowPageLimitationL();
   const { data: showPageLimitationL } = useShowPageLimitationL();
+
+  // routerRef solve the problem of infinite redrawing that occurs with routers
   const router = useRouter();
   const router = useRouter();
+  const routerRef = useRef(router);
 
 
   // parse URL Query
   // parse URL Query
   const queries = router.query.q;
   const queries = router.query.q;
@@ -165,10 +168,10 @@ export const SearchPage = (): JSX.Element => {
 
 
   const initialSearchConditions: Partial<ISearchConditions> = useMemo(() => {
   const initialSearchConditions: Partial<ISearchConditions> = useMemo(() => {
     return {
     return {
-      keyword: initQ,
+      keyword,
       limit: INITIAL_PAGIONG_SIZE,
       limit: INITIAL_PAGIONG_SIZE,
     };
     };
-  }, [initQ]);
+  }, [keyword]);
 
 
   // for bulk deletion
   // for bulk deletion
   const deleteAllButtonClickedHandler = usePageDeleteModalForBulkDeletion(data, searchPageBaseRef, () => mutate());
   const deleteAllButtonClickedHandler = usePageDeleteModalForBulkDeletion(data, searchPageBaseRef, () => mutate());
@@ -177,8 +180,21 @@ export const SearchPage = (): JSX.Element => {
   useEffect(() => {
   useEffect(() => {
     const newUrl = new URL('/_search', 'http://example.com');
     const newUrl = new URL('/_search', 'http://example.com');
     newUrl.searchParams.append('q', keyword);
     newUrl.searchParams.append('q', keyword);
-    window.history.pushState('', `Search - ${keyword}`, `${newUrl.pathname}${newUrl.search}`);
-  }, [keyword]);
+    routerRef.current.push(`${newUrl.pathname}${newUrl.search}`, '', { shallow: true });
+  }, [keyword, routerRef]);
+
+  // browser back and forward
+  useEffect(() => {
+    routerRef.current.beforePopState(({ url }) => {
+      const newUrl = new URL(url, 'https://exmple.com');
+      const newKeyword = newUrl.searchParams.get('q');
+      if (newKeyword != null) {
+        setKeyword(newKeyword);
+      }
+      return true;
+    });
+  }, [setKeyword, routerRef]);
+
   const hitsCount = data?.meta.hitsCount;
   const hitsCount = data?.meta.hitsCount;
 
 
   const allControl = useMemo(() => {
   const allControl = useMemo(() => {

+ 14 - 5
apps/app/src/components/SearchPage/SearchPageBase.tsx

@@ -43,8 +43,13 @@ type Props = {
   searchPager: React.ReactNode,
   searchPager: React.ReactNode,
 }
 }
 
 
+
+const SearchResultContent = dynamic(() => import('./SearchResultContent').then(mod => mod.SearchResultContent), {
+  ssr: false,
+  loading: () => <></>,
+});
 const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturnSelectedPageIds, Props> = (props:Props, ref) => {
 const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturnSelectedPageIds, Props> = (props:Props, ref) => {
-  const SearchResultContent = dynamic(import('./SearchResultContent').then(mod => mod.SearchResultContent), { ssr: false });
+
   const {
   const {
     pages,
     pages,
     searchingKeyword,
     searchingKeyword,
@@ -62,6 +67,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
 
 
   const [selectedPageIdsByCheckboxes] = useState<Set<string>>(new Set());
   const [selectedPageIdsByCheckboxes] = useState<Set<string>>(new Set());
   // const [allPageIds] = useState<Set<string>>(new Set());
   // const [allPageIds] = useState<Set<string>>(new Set());
+
   const [selectedPageWithMeta, setSelectedPageWithMeta] = useState<IPageWithSearchMeta | undefined>();
   const [selectedPageWithMeta, setSelectedPageWithMeta] = useState<IPageWithSearchMeta | undefined>();
 
 
   // publish selectAll()
   // publish selectAll()
@@ -108,10 +114,13 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
 
 
   // select first item on load
   // select first item on load
   useEffect(() => {
   useEffect(() => {
-    if (selectedPageWithMeta == null && pages != null && pages.length > 0) {
+    if ((pages == null || pages.length === 0)) {
+      setSelectedPageWithMeta(undefined);
+    }
+    else if ((pages != null && pages.length > 0)) {
       setSelectedPageWithMeta(pages[0]);
       setSelectedPageWithMeta(pages[0]);
     }
     }
-  }, [pages, selectedPageWithMeta]);
+  }, [pages, setSelectedPageWithMeta]);
 
 
   // reset selectedPageIdsByCheckboxes
   // reset selectedPageIdsByCheckboxes
   useEffect(() => {
   useEffect(() => {
@@ -189,7 +198,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
                       pages={pages}
                       pages={pages}
                       selectedPageId={selectedPageWithMeta?.data._id}
                       selectedPageId={selectedPageWithMeta?.data._id}
                       forceHideMenuItems={forceHideMenuItems}
                       forceHideMenuItems={forceHideMenuItems}
-                      onPageSelected={page => setSelectedPageWithMeta(page)}
+                      onPageSelected={page => (setSelectedPageWithMeta(page)) }
                       onCheckboxChanged={checkboxChangedHandler}
                       onCheckboxChanged={checkboxChangedHandler}
                     />
                     />
                   </div>
                   </div>
@@ -205,7 +214,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
         </div>
         </div>
 
 
         <div className="mw-0 flex-grow-1 flex-basis-0 d-none d-lg-block search-result-content">
         <div className="mw-0 flex-grow-1 flex-basis-0 d-none d-lg-block search-result-content">
-          { selectedPageWithMeta != null && (
+          {pages != null && pages.length !== 0 && selectedPageWithMeta != null && (
             <SearchResultContent
             <SearchResultContent
               pageWithMeta={selectedPageWithMeta}
               pageWithMeta={selectedPageWithMeta}
               highlightKeywords={highlightKeywords}
               highlightKeywords={highlightKeywords}

+ 3 - 2
apps/app/src/components/Sidebar/SidebarNav.tsx

@@ -6,7 +6,7 @@ import Link from 'next/link';
 
 
 import { useUserUISettings } from '~/client/services/user-ui-settings';
 import { useUserUISettings } from '~/client/services/user-ui-settings';
 import { SidebarContentsType } from '~/interfaces/ui';
 import { SidebarContentsType } from '~/interfaces/ui';
-import { useCurrentUser } from '~/stores/context';
+import { useCurrentUser, useGrowiCloudUri } from '~/stores/context';
 import { useCurrentSidebarContents } from '~/stores/ui';
 import { useCurrentSidebarContents } from '~/stores/ui';
 
 
 import styles from './SidebarNav.module.scss';
 import styles from './SidebarNav.module.scss';
@@ -84,6 +84,7 @@ type Props = {
 export const SidebarNav: FC<Props> = (props: Props) => {
 export const SidebarNav: FC<Props> = (props: Props) => {
 
 
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
+  const { data: growiCloudUri } = useGrowiCloudUri();
 
 
   const [isAdmin, setAdmin] = useState(false);
   const [isAdmin, setAdmin] = useState(false);
 
 
@@ -110,7 +111,7 @@ export const SidebarNav: FC<Props> = (props: Props) => {
       <div className="grw-sidebar-nav-secondary-container">
       <div className="grw-sidebar-nav-secondary-container">
         {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
         {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
         {/* <SecondaryItem label="Draft" iconName="file_copy" href="/me/drafts" /> */}
         {/* <SecondaryItem label="Draft" iconName="file_copy" href="/me/drafts" /> */}
-        <SecondaryItem label="Help" iconName="help" href="https://docs.growi.org" isBlank />
+        <SecondaryItem label="Help" iconName="help" href={ growiCloudUri != null ? 'https://growi.cloud/help/' : 'https://docs.growi.org' } isBlank />
         <SecondaryItem label="Trash" iconName="delete" href="/trash" />
         <SecondaryItem label="Trash" iconName="delete" href="/trash" />
       </div>
       </div>
     </div>
     </div>

+ 1 - 6
apps/app/src/components/User/Username.tsx

@@ -3,12 +3,7 @@ import React from 'react';
 import type { IUser } from '@growi/core';
 import type { IUser } from '@growi/core';
 import Link from 'next/link';
 import Link from 'next/link';
 
 
-type UsernameProps = {
- user?: IUser,
-}
-
-export const Username = (props: UsernameProps): JSX.Element => {
-  const { user } = props;
+export const Username: React.FC<{ user?: IUser }> = ({ user }): JSX.Element => {
 
 
   if (user == null) {
   if (user == null) {
     return <span>anyone</span>;
     return <span>anyone</span>;

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

@@ -35,7 +35,7 @@ import {
   useHackmdUri, useDefaultIndentSize, useIsIndentSizeForced,
   useHackmdUri, useDefaultIndentSize, useIsIndentSizeForced,
   useIsAclEnabled, useIsSearchPage, useIsEnabledAttachTitleHeader,
   useIsAclEnabled, useIsSearchPage, useIsEnabledAttachTitleHeader,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPathname,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPathname,
-  useIsSlackConfigured, useRendererConfig,
+  useIsSlackConfigured, useRendererConfig, useGrowiCloudUri,
   useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, useIsContainerFluid, useIsNotCreatable,
   useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, useIsContainerFluid, useIsNotCreatable,
 } from '~/stores/context';
 } from '~/stores/context';
 import { useEditingMarkdown } from '~/stores/editor';
 import { useEditingMarkdown } from '~/stores/editor';
@@ -193,6 +193,7 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   // commons
   // commons
   useEditorConfig(props.editorConfig);
   useEditorConfig(props.editorConfig);
   useCsrfToken(props.csrfToken);
   useCsrfToken(props.csrfToken);
+  useGrowiCloudUri(props.growiCloudUri);
 
 
   // page
   // page
   useIsContainerFluid(props.isContainerFluid);
   useIsContainerFluid(props.isContainerFluid);

+ 2 - 1
apps/app/src/pages/_private-legacy-pages.page.tsx

@@ -12,7 +12,7 @@ import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { IUser, IUserHasId } from '~/interfaces/user';
 import type { IUser, IUserHasId } from '~/interfaces/user';
 import {
 import {
   useCsrfToken, useCurrentUser, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
   useCsrfToken, useCurrentUser, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
-  useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig,
+  useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useGrowiCloudUri,
 } from '~/stores/context';
 } from '~/stores/context';
 
 
 import type { CommonProps } from './utils/commons';
 import type { CommonProps } from './utils/commons';
@@ -41,6 +41,7 @@ const PrivateLegacyPage: NextPage<Props> = (props: Props) => {
 
 
   // commons
   // commons
   useCsrfToken(props.csrfToken);
   useCsrfToken(props.csrfToken);
+  useGrowiCloudUri(props.growiCloudUri);
 
 
   useCurrentUser(props.currentUser ?? null);
   useCurrentUser(props.currentUser ?? null);
 
 

+ 2 - 1
apps/app/src/pages/_search.page.tsx

@@ -13,7 +13,7 @@ import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { IUser, IUserHasId } from '~/interfaces/user';
 import type { IUser, IUserHasId } from '~/interfaces/user';
 import {
 import {
   useCsrfToken, useCurrentUser, useIsContainerFluid, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
   useCsrfToken, useCurrentUser, useIsContainerFluid, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
-  useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useShowPageLimitationL,
+  useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useShowPageLimitationL, useGrowiCloudUri,
 } from '~/stores/context';
 } from '~/stores/context';
 
 
 import { SearchPage } from '../components/SearchPage';
 import { SearchPage } from '../components/SearchPage';
@@ -47,6 +47,7 @@ const SearchResultPage: NextPageWithLayout<Props> = (props: Props) => {
 
 
   // commons
   // commons
   useCsrfToken(props.csrfToken);
   useCsrfToken(props.csrfToken);
+  useGrowiCloudUri(props.growiCloudUri);
 
 
   useCurrentUser(props.currentUser ?? null);
   useCurrentUser(props.currentUser ?? null);
 
 

+ 2 - 1
apps/app/src/pages/me/[[...path]].page.tsx

@@ -14,7 +14,7 @@ import { BasicLayout } from '~/components/Layout/BasicLayout';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import {
 import {
-  useCurrentUser, useIsSearchPage,
+  useCurrentUser, useIsSearchPage, useGrowiCloudUri,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useCsrfToken, useIsSearchScopeChildrenAsDefault,
   useCsrfToken, useIsSearchScopeChildrenAsDefault,
   useRegistrationWhitelist, useShowPageLimitationXL, useRendererConfig,
   useRegistrationWhitelist, useShowPageLimitationXL, useRendererConfig,
@@ -88,6 +88,7 @@ const MePage: NextPageWithLayout<Props> = (props: Props) => {
 
 
   // commons
   // commons
   useCsrfToken(props.csrfToken);
   useCsrfToken(props.csrfToken);
+  useGrowiCloudUri(props.growiCloudUri);
 
 
   // init sidebar config with UserUISettings and sidebarConfig
   // init sidebar config with UserUISettings and sidebarConfig
   useInitSidebarConfig(props.sidebarConfig, props.userUISettings);
   useInitSidebarConfig(props.sidebarConfig, props.userUISettings);

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

@@ -16,7 +16,7 @@ import { BasicLayout } from '../components/Layout/BasicLayout';
 import {
 import {
   useCurrentUser, useIsSearchPage,
   useCurrentUser, useIsSearchPage,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
-  useIsSearchScopeChildrenAsDefault,
+  useIsSearchScopeChildrenAsDefault, useGrowiCloudUri,
 } from '../stores/context';
 } from '../stores/context';
 
 
 import { NextPageWithLayout } from './_app.page';
 import { NextPageWithLayout } from './_app.page';
@@ -55,6 +55,7 @@ const TagPage: NextPageWithLayout<CommonProps> = (props: Props) => {
   const totalCount: number = tagDataList?.totalCount || 0;
   const totalCount: number = tagDataList?.totalCount || 0;
   const isLoading = tagDataList === undefined && error == null;
   const isLoading = tagDataList === undefined && error == null;
 
 
+  useGrowiCloudUri(props.growiCloudUri);
 
 
   useIsSearchPage(false);
   useIsSearchPage(false);
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);

+ 3 - 1
apps/app/src/pages/trash.page.tsx

@@ -14,7 +14,7 @@ import { useDrawerMode } from '~/stores/ui';
 
 
 import { BasicLayout } from '../components/Layout/BasicLayout';
 import { BasicLayout } from '../components/Layout/BasicLayout';
 import {
 import {
-  useCurrentUser, useCurrentPathname,
+  useCurrentUser, useCurrentPathname, useGrowiCloudUri,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useIsSearchScopeChildrenAsDefault, useIsSearchPage, useShowPageLimitationXL, useIsGuestUser, useIsReadOnlyUser,
   useIsSearchScopeChildrenAsDefault, useIsSearchPage, useShowPageLimitationXL, useIsGuestUser, useIsReadOnlyUser,
 } from '../stores/context';
 } from '../stores/context';
@@ -42,6 +42,8 @@ type Props = CommonProps & {
 const TrashPage: NextPageWithLayout<CommonProps> = (props: Props) => {
 const TrashPage: NextPageWithLayout<CommonProps> = (props: Props) => {
   useCurrentUser(props.currentUser ?? null);
   useCurrentUser(props.currentUser ?? null);
 
 
+  useGrowiCloudUri(props.growiCloudUri);
+
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);

+ 2 - 0
apps/app/src/pages/utils/commons.ts

@@ -30,6 +30,7 @@ export type CommonProps = {
   isMaintenanceMode: boolean,
   isMaintenanceMode: boolean,
   redirectDestination: string | null,
   redirectDestination: string | null,
   isDefaultLogo: boolean,
   isDefaultLogo: boolean,
+  growiCloudUri: string,
   currentUser?: IUserHasId,
   currentUser?: IUserHasId,
   forcedColorScheme?: ColorScheme,
   forcedColorScheme?: ColorScheme,
   sidebarConfig: ISidebarConfig,
   sidebarConfig: ISidebarConfig,
@@ -96,6 +97,7 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
     currentUser,
     currentUser,
     isDefaultLogo,
     isDefaultLogo,
     forcedColorScheme,
     forcedColorScheme,
+    growiCloudUri: configManager.getConfig('crowi', 'app:growiCloudUri'),
     sidebarConfig: {
     sidebarConfig: {
       isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
       isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
       isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
       isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),

+ 16 - 0
apps/app/src/server/models/user.js

@@ -144,6 +144,22 @@ module.exports = function(crowi) {
     return hasher.digest('base64');
     return hasher.digest('base64');
   }
   }
 
 
+  userSchema.methods.isUniqueEmail = async function() {
+    const query = this.model('User').find();
+
+    const count = await query.count((
+      {
+        username: { $ne: this.username },
+        email: this.email,
+      }
+    ));
+
+    if (count > 0) {
+      return false;
+    }
+    return true;
+  };
+
   userSchema.methods.isPasswordSet = function() {
   userSchema.methods.isPasswordSet = function() {
     if (this.password) {
     if (this.password) {
       return true;
       return true;

+ 45 - 0
apps/app/src/server/routes/apiv3/attachment.js

@@ -26,12 +26,57 @@ module.exports = (crowi) => {
   const Attachment = crowi.model('Attachment');
   const Attachment = crowi.model('Attachment');
 
 
   const validator = {
   const validator = {
+    attachment: [
+      query('attachmentId').isMongoId().withMessage('attachmentId is required'),
+    ],
     retrieveAttachments: [
     retrieveAttachments: [
       query('pageId').isMongoId().withMessage('pageId is required'),
       query('pageId').isMongoId().withMessage('pageId is required'),
       query('pageNumber').optional().isInt().withMessage('pageNumber must be a number'),
       query('pageNumber').optional().isInt().withMessage('pageNumber must be a number'),
       query('limit').optional().isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
       query('limit').optional().isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
     ],
     ],
   };
   };
+
+  /**
+   * @swagger
+   *
+   *    /attachment:
+   *      get:
+   *        tags: [Attachment]
+   *        description: Get attachment
+   *        responses:
+   *          200:
+   *            description: Return attachment
+   *        parameters:
+   *          - name: attachemnt_id
+   *            in: query
+   *            required: true
+   *            description: attachment id
+   *            schema:
+   *              type: string
+   */
+  router.get('/', accessTokenParser, loginRequired, validator.attachment, apiV3FormValidator, async(req, res) => {
+    try {
+      const attachmentId = req.query.attachmentId;
+
+      const attachment = await Attachment.findById(attachmentId).populate('creator').exec();
+
+      if (attachment == null) {
+        const message = 'Attachment not found';
+        return res.apiv3Err(message, 404);
+      }
+
+      if (attachment.creator != null && attachment.creator instanceof User) {
+        attachment.creator = serializeUserSecurely(attachment.creator);
+      }
+
+      return res.apiv3({ attachment });
+    }
+    catch (err) {
+      logger.error('Attachment retrieval failed', err);
+      return res.apiv3Err(err, 500);
+    }
+  });
+
   /**
   /**
    * @swagger
    * @swagger
    *
    *

+ 8 - 8
apps/app/src/server/routes/apiv3/forgot-password.js

@@ -5,13 +5,13 @@ import { SupportedAction } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import injectResetOrderByTokenMiddleware from '~/server/middlewares/inject-reset-order-by-token-middleware';
 import injectResetOrderByTokenMiddleware from '~/server/middlewares/inject-reset-order-by-token-middleware';
 import PasswordResetOrder from '~/server/models/password-reset-order';
 import PasswordResetOrder from '~/server/models/password-reset-order';
+import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import httpErrorHandler from '../../middlewares/http-error-handler';
 import httpErrorHandler from '../../middlewares/http-error-handler';
 import { checkForgotPasswordEnabledMiddlewareFactory } from '../forgot-password';
 import { checkForgotPasswordEnabledMiddlewareFactory } from '../forgot-password';
 
 
-
 const logger = loggerFactory('growi:routes:apiv3:forgotPassword'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:forgotPassword'); // eslint-disable-line no-unused-vars
 
 
 const express = require('express');
 const express = require('express');
@@ -22,7 +22,7 @@ const { serializeUserSecurely } = require('../../models/serializers/user-seriali
 const router = express.Router();
 const router = express.Router();
 
 
 module.exports = (crowi) => {
 module.exports = (crowi) => {
-  const { appService, mailService, configManager } = crowi;
+  const { appService, mailService } = crowi;
   const User = crowi.model('User');
   const User = crowi.model('User');
   const path = require('path');
   const path = require('path');
 
 
@@ -30,7 +30,7 @@ module.exports = (crowi) => {
 
 
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
 
 
-  const minPasswordLength = crowi.configManager.getConfig('crowi', 'app:minPasswordLength');
+  const minPasswordLength = configManager.getConfig('crowi', 'app:minPasswordLength');
 
 
   const validator = {
   const validator = {
     password: [
     password: [
@@ -47,11 +47,11 @@ module.exports = (crowi) => {
 
 
   const checkPassportStrategyMiddleware = checkForgotPasswordEnabledMiddlewareFactory(crowi, true);
   const checkPassportStrategyMiddleware = checkForgotPasswordEnabledMiddlewareFactory(crowi, true);
 
 
-  async function sendPasswordResetEmail(txtFileName, i18n, email, url, expiredAt) {
+  async function sendPasswordResetEmail(templateFileName, locale, email, url, expiredAt) {
     return mailService.send({
     return mailService.send({
       to: email,
       to: email,
       subject: '[GROWI] Password Reset',
       subject: '[GROWI] Password Reset',
-      template: path.join(crowi.localeDir, `${i18n}/notifications/${txtFileName}.txt`),
+      template: path.join(crowi.localeDir, `${locale}/notifications/${templateFileName}.ejs`),
       vars: {
       vars: {
         appTitle: appService.getAppTitle(),
         appTitle: appService.getAppTitle(),
         email,
         email,
@@ -63,7 +63,7 @@ module.exports = (crowi) => {
 
 
   router.post('/', checkPassportStrategyMiddleware, addActivity, async(req, res) => {
   router.post('/', checkPassportStrategyMiddleware, addActivity, async(req, res) => {
     const { email } = req.body;
     const { email } = req.body;
-    const i18n = configManager.getConfig('crowi', 'app:globalLang');
+    const locale = configManager.getConfig('crowi', 'app:globalLang');
     const appUrl = appService.getSiteUrl();
     const appUrl = appService.getSiteUrl();
 
 
     try {
     try {
@@ -71,7 +71,7 @@ module.exports = (crowi) => {
 
 
       // when the user is not found or active
       // when the user is not found or active
       if (user == null || user.status !== 2) {
       if (user == null || user.status !== 2) {
-        await sendPasswordResetEmail('notActiveUser', i18n, email, appUrl);
+        await sendPasswordResetEmail('notActiveUser', locale, email, appUrl);
         return res.apiv3();
         return res.apiv3();
       }
       }
 
 
@@ -81,7 +81,7 @@ module.exports = (crowi) => {
       const grwTzoffsetSec = crowi.appService.getTzoffset() * 60;
       const grwTzoffsetSec = crowi.appService.getTzoffset() * 60;
       const expiredAt = subSeconds(passwordResetOrderData.expiredAt, grwTzoffsetSec);
       const expiredAt = subSeconds(passwordResetOrderData.expiredAt, grwTzoffsetSec);
       const formattedExpiredAt = format(expiredAt, 'yyyy/MM/dd HH:mm');
       const formattedExpiredAt = format(expiredAt, 'yyyy/MM/dd HH:mm');
-      await sendPasswordResetEmail('passwordReset', i18n, email, oneTimeUrl, formattedExpiredAt);
+      await sendPasswordResetEmail('passwordReset', locale, email, oneTimeUrl, formattedExpiredAt);
 
 
       activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_USER_FOGOT_PASSWORD });
       activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_USER_FOGOT_PASSWORD });
 
 

+ 9 - 0
apps/app/src/server/routes/apiv3/personal-setting.js

@@ -1,5 +1,7 @@
+import { ErrorV3 } from '@growi/core';
 import { body } from 'express-validator';
 import { body } from 'express-validator';
 
 
+
 import { i18n } from '^/config/next-i18next.config';
 import { i18n } from '^/config/next-i18next.config';
 
 
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
@@ -238,6 +240,13 @@ module.exports = (crowi) => {
       user.isEmailPublished = req.body.isEmailPublished;
       user.isEmailPublished = req.body.isEmailPublished;
       user.slackMemberId = req.body.slackMemberId;
       user.slackMemberId = req.body.slackMemberId;
 
 
+      const isUniqueEmail = await user.isUniqueEmail();
+
+      if (!isUniqueEmail) {
+        logger.error('email-is-not-unique');
+        return res.apiv3Err(new ErrorV3('The email is already in use', 'email-is-already-in-use'));
+      }
+
       const updatedUser = await user.save();
       const updatedUser = await user.save();
 
 
       const parameters = { action: SupportedAction.ACTION_USER_PERSONAL_SETTINGS_UPDATE };
       const parameters = { action: SupportedAction.ACTION_USER_PERSONAL_SETTINGS_UPDATE };

+ 6 - 9
apps/app/src/server/routes/apiv3/user-activation.ts

@@ -7,9 +7,9 @@ import { body, validationResult } from 'express-validator';
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
 import { RegistrationMode } from '~/interfaces/registration-mode';
 import { RegistrationMode } from '~/interfaces/registration-mode';
 import UserRegistrationOrder from '~/server/models/user-registration-order';
 import UserRegistrationOrder from '~/server/models/user-registration-order';
+import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-
 const logger = loggerFactory('growi:routes:apiv3:user-activation');
 const logger = loggerFactory('growi:routes:apiv3:user-activation');
 
 
 const PASSOWRD_MINIMUM_NUMBER = 8;
 const PASSOWRD_MINIMUM_NUMBER = 8;
@@ -69,7 +69,6 @@ export const completeRegistrationAction = (crowi) => {
   const User = crowi.model('User');
   const User = crowi.model('User');
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
   const {
   const {
-    configManager,
     aclService,
     aclService,
     appService,
     appService,
     mailService,
     mailService,
@@ -142,7 +141,8 @@ export const completeRegistrationAction = (crowi) => {
           if (isMailerSetup) {
           if (isMailerSetup) {
             const admins = await User.findAdmins();
             const admins = await User.findAdmins();
             const appTitle = appService.getAppTitle();
             const appTitle = appService.getAppTitle();
-            const template = path.join(crowi.localeDir, 'en_US/admin/userWaitingActivation.txt');
+            const locale = configManager.getConfig('crowi', 'app:globalLang');
+            const template = path.join(crowi.localeDir, `${locale}/admin/userWaitingActivation.ejs`);
             const url = appService.getSiteUrl();
             const url = appService.getSiteUrl();
 
 
             sendEmailToAllAdmins(userData, admins, appTitle, mailService, template, url);
             sendEmailToAllAdmins(userData, admins, appTitle, mailService, template, url);
@@ -205,7 +205,6 @@ export const validateRegisterForm = (req, res, next) => {
 
 
 async function makeRegistrationEmailToken(email, crowi) {
 async function makeRegistrationEmailToken(email, crowi) {
   const {
   const {
-    configManager,
     mailService,
     mailService,
     localeDir,
     localeDir,
     appService,
     appService,
@@ -216,8 +215,7 @@ async function makeRegistrationEmailToken(email, crowi) {
     throw Error('mailService is not setup');
     throw Error('mailService is not setup');
   }
   }
 
 
-  const grobalLang = configManager.getConfig('crowi', 'app:globalLang');
-  const i18n = grobalLang;
+  const locale = configManager.getConfig('crowi', 'app:globalLang');
   const appUrl = appService.getSiteUrl();
   const appUrl = appService.getSiteUrl();
 
 
   const userRegistrationOrder = await UserRegistrationOrder.createUserRegistrationOrder(email);
   const userRegistrationOrder = await UserRegistrationOrder.createUserRegistrationOrder(email);
@@ -226,12 +224,11 @@ async function makeRegistrationEmailToken(email, crowi) {
   const formattedExpiredAt = format(expiredAt, 'yyyy/MM/dd HH:mm');
   const formattedExpiredAt = format(expiredAt, 'yyyy/MM/dd HH:mm');
   const url = new URL(`/user-activation/${userRegistrationOrder.token}`, appUrl);
   const url = new URL(`/user-activation/${userRegistrationOrder.token}`, appUrl);
   const oneTimeUrl = url.href;
   const oneTimeUrl = url.href;
-  const txtFileName = 'userActivation';
 
 
   return mailService.send({
   return mailService.send({
     to: email,
     to: email,
     subject: '[GROWI] User Activation',
     subject: '[GROWI] User Activation',
-    template: path.join(localeDir, `${i18n}/notifications/${txtFileName}.txt`),
+    template: path.join(localeDir, `${locale}/notifications/userActivation.ejs`),
     vars: {
     vars: {
       appTitle: appService.getAppTitle(),
       appTitle: appService.getAppTitle(),
       email,
       email,
@@ -248,7 +245,7 @@ export const registerAction = (crowi) => {
     const registerForm = req.body.registerForm || {};
     const registerForm = req.body.registerForm || {};
     const email = registerForm.email;
     const email = registerForm.email;
     const isRegisterableEmail = await User.isRegisterableEmail(email);
     const isRegisterableEmail = await User.isRegisterableEmail(email);
-    const registrationMode = crowi.configManager.getConfig('crowi', 'security:registrationMode') as RegistrationMode;
+    const registrationMode = configManager.getConfig('crowi', 'security:registrationMode') as RegistrationMode;
     const isEmailValid = await User.isEmailValid(email);
     const isEmailValid = await User.isEmailValid(email);
 
 
     if (registrationMode === RegistrationMode.CLOSED) {
     if (registrationMode === RegistrationMode.CLOSED) {

+ 68 - 3
apps/app/src/server/routes/apiv3/users.js

@@ -2,12 +2,12 @@ import { ErrorV3 } from '@growi/core';
 
 
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
 import Activity from '~/server/models/activity';
 import Activity from '~/server/models/activity';
+import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
-
 const logger = loggerFactory('growi:routes:apiv3:users');
 const logger = loggerFactory('growi:routes:apiv3:users');
 
 
 const path = require('path');
 const path = require('path');
@@ -150,6 +150,7 @@ module.exports = (crowi) => {
   const sendEmailByUserList = async(userList) => {
   const sendEmailByUserList = async(userList) => {
     const { appService, mailService } = crowi;
     const { appService, mailService } = crowi;
     const appTitle = appService.getAppTitle();
     const appTitle = appService.getAppTitle();
+    const locale = configManager.getConfig('crowi', 'app:globalLang');
     const failedToSendEmailList = [];
     const failedToSendEmailList = [];
 
 
     for (const user of userList) {
     for (const user of userList) {
@@ -158,7 +159,7 @@ module.exports = (crowi) => {
         await mailService.send({
         await mailService.send({
           to: user.email,
           to: user.email,
           subject: `Invitation to ${appTitle}`,
           subject: `Invitation to ${appTitle}`,
-          template: path.join(crowi.localeDir, 'en_US/admin/userInvitation.txt'),
+          template: path.join(crowi.localeDir, `${locale}/admin/userInvitation.ejs`),
           vars: {
           vars: {
             email: user.email,
             email: user.email,
             password: user.password,
             password: user.password,
@@ -181,6 +182,23 @@ module.exports = (crowi) => {
     return { failedToSendEmailList };
     return { failedToSendEmailList };
   };
   };
 
 
+  const sendEmailByUser = async(user) => {
+    const { appService, mailService } = crowi;
+    const appTitle = appService.getAppTitle();
+
+    await mailService.send({
+      to: user.email,
+      subject: `New password for ${appTitle}`,
+      template: path.join(crowi.localeDir, 'en_US/admin/userResetPassword.txt'),
+      vars: {
+        email: user.email,
+        password: user.password,
+        url: crowi.appService.getSiteUrl(),
+        appTitle,
+      },
+    });
+  };
+
   /**
   /**
    * @swagger
    * @swagger
    *
    *
@@ -947,7 +965,7 @@ module.exports = (crowi) => {
    *                    description: user id for reset password
    *                    description: user id for reset password
    *        responses:
    *        responses:
    *          200:
    *          200:
-   *            description: success resrt password
+   *            description: success reset password
    */
    */
   router.put('/reset-password', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
   router.put('/reset-password', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
     const { id } = req.body;
     const { id } = req.body;
@@ -966,6 +984,53 @@ module.exports = (crowi) => {
     }
     }
   });
   });
 
 
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /users/reset-password-email:
+   *      put:
+   *        tags: [Users]
+   *        operationId: resetPasswordEmail
+   *        summary: /users/reset-password-email
+   *        description: send new password email
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  newPassword:
+   *                    type: string
+   *                  user:
+   *                    type: string
+   *                    description: user id for send new password email
+   *        responses:
+   *          200:
+   *            description: success send new password email
+   */
+  router.put('/reset-password-email', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
+    const { id } = req.body;
+
+    try {
+      const user = await User.findById(id);
+      if (user == null) {
+        throw new Error('User not found');
+      }
+      const userInfo = {
+        email: user.email,
+        password: req.body.newPassword,
+      };
+
+      await sendEmailByUser(userInfo);
+      return res.apiv3();
+    }
+    catch (err) {
+      const msg = err.message;
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg));
+    }
+  });
+
   /**
   /**
    * @swagger
    * @swagger
    *
    *

+ 4 - 3
apps/app/src/server/routes/login.js

@@ -1,4 +1,5 @@
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
+import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 // disable all of linting
 // disable all of linting
@@ -10,7 +11,7 @@ module.exports = function(crowi, app) {
   const path = require('path');
   const path = require('path');
   const User = crowi.model('User');
   const User = crowi.model('User');
   const {
   const {
-    configManager, appService, aclService, mailService, activityService,
+    appService, aclService, mailService, activityService,
   } = crowi;
   } = crowi;
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
 
 
@@ -19,14 +20,14 @@ module.exports = function(crowi, app) {
   async function sendEmailToAllAdmins(userData) {
   async function sendEmailToAllAdmins(userData) {
     // send mails to all admin users (derived from crowi) -- 2020.06.18 Yuki Takei
     // send mails to all admin users (derived from crowi) -- 2020.06.18 Yuki Takei
     const admins = await User.findAdmins();
     const admins = await User.findAdmins();
-
     const appTitle = appService.getAppTitle();
     const appTitle = appService.getAppTitle();
+    const locale = configManager.getConfig('crowi', 'app:globalLang');
 
 
     const promises = admins.map((admin) => {
     const promises = admins.map((admin) => {
       return mailService.send({
       return mailService.send({
         to: admin.email,
         to: admin.email,
         subject: `[${appTitle}:admin] A New User Created and Waiting for Activation`,
         subject: `[${appTitle}:admin] A New User Created and Waiting for Activation`,
-        template: path.join(crowi.localeDir, 'en_US/admin/userWaitingActivation.txt'),
+        template: path.join(crowi.localeDir, `${locale}/admin/userWaitingActivation.ejs`),
         vars: {
         vars: {
           adminUser: admin,
           adminUser: admin,
           createdUser: userData,
           createdUser: userData,

+ 3 - 2
apps/app/src/server/service/global-notification/global-notification-mail.js

@@ -1,3 +1,4 @@
+import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('growi:service:GlobalNotificationMailService'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:service:GlobalNotificationMailService'); // eslint-disable-line no-unused-vars
@@ -50,13 +51,13 @@ class GlobalNotificationMailService {
    * @return  {{ subject: string, template: string, vars: object }}
    * @return  {{ subject: string, template: string, vars: object }}
    */
    */
   generateOption(event, page, triggeredBy, { comment, oldPath }) {
   generateOption(event, page, triggeredBy, { comment, oldPath }) {
-    const defaultLang = this.crowi.configManager.getConfig('crowi', 'app:globalLang');
+    const locale = configManager.getConfig('crowi', 'app:globalLang');
     // validate for all events
     // validate for all events
     if (event == null || page == null || triggeredBy == null) {
     if (event == null || page == null || triggeredBy == null) {
       throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);
       throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);
     }
     }
 
 
-    const template = nodePath.join(this.crowi.localeDir, `${defaultLang}/notifications/${event}.txt`);
+    const template = nodePath.join(this.crowi.localeDir, `${locale}/notifications/${event}.ejs`);
 
 
     const path = page.path;
     const path = page.path;
     const appTitle = this.crowi.appService.getAppTitle();
     const appTitle = this.crowi.appService.getAppTitle();

+ 44 - 0
apps/app/src/services/renderer/remark-plugins/attachment.ts

@@ -0,0 +1,44 @@
+import path from 'path';
+
+import { Schema as SanitizeOption } from 'hast-util-sanitize';
+import { Plugin } from 'unified';
+import { Node } from 'unist';
+import { visit } from 'unist-util-visit';
+
+const SUPPORTED_ATTRIBUTES = ['attachmentId', 'url', 'attachmentName'];
+
+const isAttachmentLink = (url: string) => {
+  // https://regex101.com/r/9qZhiK/1
+  const attachmentUrlFormat = new RegExp(/^\/(attachment)\/([^/^\n]+)$/);
+  return url.match(attachmentUrlFormat);
+};
+
+const rewriteNode = (node: Node) => {
+  const attachmentId = path.basename(node.url as string);
+  const data = node.data ?? (node.data = {});
+  data.hName = 'attachment';
+  data.hProperties = {
+    attachmentId,
+    url: node.url,
+    attachmentName: (node.children as any)[0]?.value,
+  };
+};
+
+export const remarkPlugin: Plugin = () => {
+  return (tree) => {
+    visit(tree, (node) => {
+      if (node.type === 'link') {
+        if (isAttachmentLink(node.url as string)) {
+          rewriteNode(node);
+        }
+      }
+    });
+  };
+};
+
+export const sanitizeOption: SanitizeOption = {
+  tagNames: ['attachment'],
+  attributes: {
+    attachment: SUPPORTED_ATTRIBUTES,
+  },
+};

+ 31 - 3
apps/app/src/stores/attachment.tsx

@@ -3,7 +3,8 @@ import { useCallback } from 'react';
 import {
 import {
   IAttachmentHasId, Nullable, type SWRResponseWithUtils, withUtils,
   IAttachmentHasId, Nullable, type SWRResponseWithUtils, withUtils,
 } from '@growi/core';
 } from '@growi/core';
-import useSWR from 'swr';
+import { Util } from 'reactstrap';
+import useSWR, { useSWRConfig } from 'swr';
 
 
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
@@ -14,12 +15,37 @@ type Util = {
 };
 };
 
 
 type IDataAttachmentList = {
 type IDataAttachmentList = {
-  attachments: (IAttachmentHasId)[]
+  attachments: IAttachmentHasId[]
   totalAttachments: number
   totalAttachments: number
   limit: number
   limit: number
 };
 };
 
 
+export const useSWRxAttachment = (attachmentId: string): SWRResponseWithUtils<Util, IAttachmentHasId, Error> => {
+  const swrResponse = useSWR(
+    ['/attachment', attachmentId],
+    useCallback(async([endpoint, attachmentId]) => {
+      const params = { attachmentId };
+      const res = await apiv3Get(endpoint, params);
+      return res.data.attachment;
+    }, []),
+  );
+
+  // Utils
+  const remove = useCallback(async(body: { attachment_id: string }) => {
+    try {
+      await apiPost('/attachments.remove', body);
+      swrResponse.mutate(body.attachment_id);
+    }
+    catch (err) {
+      throw err;
+    }
+  }, [swrResponse]);
+
+  return withUtils<Util, IAttachmentHasId, Error>(swrResponse, { remove });
+};
+
 export const useSWRxAttachments = (pageId?: Nullable<string>, pageNumber?: number): SWRResponseWithUtils<Util, IDataAttachmentList, Error> => {
 export const useSWRxAttachments = (pageId?: Nullable<string>, pageNumber?: number): SWRResponseWithUtils<Util, IDataAttachmentList, Error> => {
+  const { mutate: mutateUseSWRxAttachment } = useSWRConfig();
   const shouldFetch = pageId != null && pageNumber != null;
   const shouldFetch = pageId != null && pageNumber != null;
 
 
   const fetcher = useCallback(async([endpoint, pageId, pageNumber]) => {
   const fetcher = useCallback(async([endpoint, pageId, pageNumber]) => {
@@ -45,11 +71,13 @@ export const useSWRxAttachments = (pageId?: Nullable<string>, pageNumber?: numbe
     try {
     try {
       await apiPost('/attachments.remove', body);
       await apiPost('/attachments.remove', body);
       mutate();
       mutate();
+      // Mutation for rich attachment rendering
+      mutateUseSWRxAttachment(['/attachment', body.attachment_id], body.attachment_id);
     }
     }
     catch (err) {
     catch (err) {
       throw err;
       throw err;
     }
     }
-  }, [swrResponse]);
+  }, [mutateUseSWRxAttachment, swrResponse]);
 
 
   return withUtils<Util, IDataAttachmentList, Error>(swrResponse, { remove });
   return withUtils<Util, IDataAttachmentList, Error>(swrResponse, { remove });
 };
 };

+ 46 - 0
apps/app/src/stores/modal.tsx

@@ -1,5 +1,6 @@
 import { useCallback, useMemo } from 'react';
 import { useCallback, useMemo } from 'react';
 
 
+import type { IAttachmentHasId } from '@growi/core';
 import { SWRResponse } from 'swr';
 import { SWRResponse } from 'swr';
 
 
 import Linker from '~/client/models/Linker';
 import Linker from '~/client/models/Linker';
@@ -656,6 +657,51 @@ export const useTemplateModal = (): SWRResponse<TemplateModalStatus, Error> & Te
   });
   });
 };
 };
 
 
+/**
+ * DeleteAttachmentModal
+ */
+type Remove =
+  (body: {
+    attachment_id: string;
+  }) => Promise<void>
+
+type DeleteAttachmentModalStatus = {
+  isOpened: boolean,
+  attachment?: IAttachmentHasId,
+  remove?: Remove,
+}
+
+type DeleteAttachmentModalUtils = {
+  open(
+    attachment: IAttachmentHasId,
+    remove: Remove,
+  ): void,
+  close(): void,
+}
+
+export const useDeleteAttachmentModal = (): SWRResponse<DeleteAttachmentModalStatus, Error> & DeleteAttachmentModalUtils => {
+  const initialStatus: DeleteAttachmentModalStatus = {
+    isOpened: false,
+    attachment: undefined,
+    remove: undefined,
+  };
+  const swrResponse = useStaticSWR<DeleteAttachmentModalStatus, Error>('deleteAttachmentModal', undefined, { fallbackData: initialStatus });
+  const { mutate } = swrResponse;
+
+  const open = useCallback((attachment: IAttachmentHasId, remove: Remove) => {
+    mutate({ isOpened: true, attachment, remove });
+  }, [mutate]);
+  const close = useCallback((): void => {
+    mutate({ isOpened: false });
+  }, [mutate]);
+
+  return {
+    ...swrResponse,
+    open,
+    close,
+  };
+};
+
 /*
 /*
  * LinkEditModal
  * LinkEditModal
  */
  */

+ 5 - 3
apps/app/src/stores/personal-settings.tsx

@@ -1,3 +1,4 @@
+import { ErrorV3 } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import useSWR, { SWRConfiguration, SWRResponse } from 'swr';
 import useSWR, { SWRConfiguration, SWRResponse } from 'swr';
 
 
@@ -10,6 +11,7 @@ import { apiv3Get, apiv3Put } from '../client/util/apiv3-client';
 
 
 import { useStaticSWR } from './use-static-swr';
 import { useStaticSWR } from './use-static-swr';
 
 
+
 const logger = loggerFactory('growi:stores:personal-settings');
 const logger = loggerFactory('growi:stores:personal-settings');
 
 
 
 
@@ -66,9 +68,9 @@ export const usePersonalSettings = (config?: SWRConfiguration): SWRResponse<IUse
       await apiv3Put('/personal-setting/', updateData);
       await apiv3Put('/personal-setting/', updateData);
       i18n.changeLanguage(updateData.lang);
       i18n.changeLanguage(updateData.lang);
     }
     }
-    catch (err) {
-      logger.error(err);
-      throw new Error('Failed to update personal data');
+    catch (errs) {
+      logger.error(errs);
+      throw errs;
     }
     }
   };
   };
 
 

+ 2 - 2
apps/app/src/styles/theme/apply-colors.scss

@@ -145,8 +145,8 @@ ul.pagination {
 //
 //
 
 
 .grw-logo {
 .grw-logo {
-  set transition for fill
-  svg * {
+  // set transition for fill
+  svg, svg * {
     transition: fill 0.8s ease-out;
     transition: fill 0.8s ease-out;
   }
   }
 
 

+ 0 - 1
apps/app/tsconfig.json

@@ -14,7 +14,6 @@
     "paths": {
     "paths": {
       "~/*": ["./src/*"],
       "~/*": ["./src/*"],
       "^/*": ["./*"],
       "^/*": ["./*"],
-
       "debug": ["./src/server/utils/logger/alias-for-debug"]
       "debug": ["./src/server/utils/logger/alias-for-debug"]
     }
     }
   },
   },

+ 0 - 11
apps/slackbot-proxy/bump-versions.config.js

@@ -1,11 +0,0 @@
-/*
- * Reference: https://community.algolia.com/shipjs/
- */
-module.exports = {
-  monorepo: {
-    mainVersionFile: 'package.json',
-    packagesToBump: [
-      './',
-    ],
-  },
-};

+ 4 - 3
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/slackbot-proxy",
   "name": "@growi/slackbot-proxy",
-  "version": "6.1.4-slackbot-proxy.0",
+  "version": "6.1.5-slackbot-proxy.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -18,14 +18,15 @@
     "postbuild": "yarn cp:public && yarn cp:views && yarn cp:bootstrap",
     "postbuild": "yarn cp:public && yarn cp:views && yarn cp:bootstrap",
     "predev": "yarn cp:bootstrap:dev",
     "predev": "yarn cp:bootstrap:dev",
     "lint": "yarn eslint src --ext .ts",
     "lint": "yarn eslint src --ext .ts",
-    "lint:fix": "yarn eslint src --ext .ts --fix"
+    "lint:fix": "yarn eslint src --ext .ts --fix",
+    "version": "yarn version --no-git-tag-version --preid=slackbot-proxy"
   },
   },
   "// comments for dependencies": {
   "// comments for dependencies": {
     "read-pkg-up": "v8 doesn't support CommonJS anymore. https://github.com/sindresorhus/read-pkg-up/issues/17"
     "read-pkg-up": "v8 doesn't support CommonJS anymore. https://github.com/sindresorhus/read-pkg-up/issues/17"
   },
   },
   "dependencies": {
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^6.1.4-RC.0",
+    "@growi/slack": "link:../../packages/slack",
     "@slack/oauth": "^2.0.1",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",
     "@tsed/common": "^6.43.0",

+ 0 - 4
bin/github-actions/bump-versions/README.md

@@ -1,4 +0,0 @@
-bump-versions.js
-==============
-
-Custom cli for bumping package versions based on [algolia/shipjs@0.23.3](https://github.com/algolia/shipjs/tree/v0.23.3/packages/shipjs)

Some files were not shown because too many files changed in this diff