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

Merge remote-tracking branch 'origin/master' into imprv/107549-admin-page-related-to-id

kaori 3 лет назад
Родитель
Сommit
cbaa423126
85 измененных файлов с 1427 добавлено и 1346 удалено
  1. 14 1
      CHANGELOG.md
  2. 1 1
      lerna.json
  3. 1 1
      package.json
  4. 2 10
      packages/app/_obsolete/src/client/services/PageContainer.js
  5. 2 2
      packages/app/docker/README.md
  6. 8 8
      packages/app/package.json
  7. 13 0
      packages/app/public/static/locales/en_US/admin.json
  8. 2 5
      packages/app/public/static/locales/en_US/translation.json
  9. 7 0
      packages/app/public/static/locales/ja_JP/admin.json
  10. 2 2
      packages/app/public/static/locales/ja_JP/translation.json
  11. 8 0
      packages/app/public/static/locales/zh_CN/admin.json
  12. 3 4
      packages/app/public/static/locales/zh_CN/translation.json
  13. 1 1
      packages/app/src/client/util/editor.ts
  14. 1 1
      packages/app/src/components/Admin/AuditLog/ActivityTable.tsx
  15. 0 91
      packages/app/src/components/Admin/ManageExternalAccount.jsx
  16. 79 0
      packages/app/src/components/Admin/ManageExternalAccount.tsx
  17. 4 4
      packages/app/src/components/Admin/Security/DeleteAllShareLinksModal.jsx
  18. 6 3
      packages/app/src/components/Admin/Security/SecurityManagementContents.jsx
  19. 0 208
      packages/app/src/components/Admin/Security/ShareLinkSetting.jsx
  20. 170 0
      packages/app/src/components/Admin/Security/ShareLinkSetting.tsx
  21. 1 1
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  22. 1 1
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx
  23. 0 235
      packages/app/src/components/Admin/UserManagement.jsx
  24. 43 3
      packages/app/src/components/Admin/UserManagement.module.scss
  25. 202 0
      packages/app/src/components/Admin/UserManagement.tsx
  26. 0 132
      packages/app/src/components/Admin/Users/ExternalAccountTable.jsx
  27. 8 0
      packages/app/src/components/Admin/Users/ExternalAccountTable.module.scss
  28. 122 0
      packages/app/src/components/Admin/Users/ExternalAccountTable.tsx
  29. 0 60
      packages/app/src/components/Admin/Users/GiveAdminButton.jsx
  30. 44 0
      packages/app/src/components/Admin/Users/GiveAdminButton.tsx
  31. 67 0
      packages/app/src/components/Admin/Users/RemoveAdminButton.tsx
  32. 1 1
      packages/app/src/components/Admin/Users/RemoveAdminMenuItem.tsx
  33. 9 13
      packages/app/src/components/Admin/Users/SortIcons.tsx
  34. 1 1
      packages/app/src/components/Admin/Users/StatusSuspendMenuItem.tsx
  35. 0 231
      packages/app/src/components/Admin/Users/UserTable.jsx
  36. 185 0
      packages/app/src/components/Admin/Users/UserTable.tsx
  37. 1 1
      packages/app/src/components/InvitedForm.tsx
  38. 1 1
      packages/app/src/components/Layout/RawLayout.tsx
  39. 1 1
      packages/app/src/components/LoginForm.tsx
  40. 24 27
      packages/app/src/components/Navbar/AuthorInfo.tsx
  41. 33 28
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  42. 8 9
      packages/app/src/components/PageAlert/TrashPageAlert.tsx
  43. 1 1
      packages/app/src/components/PageAttachment/DeleteAttachmentModal.tsx
  44. 4 4
      packages/app/src/components/PageComment.tsx
  45. 1 1
      packages/app/src/components/PageComment/Comment.tsx
  46. 1 1
      packages/app/src/components/PageComment/DeleteCommentModal.tsx
  47. 3 2
      packages/app/src/components/PageContentFooter.tsx
  48. 15 0
      packages/app/src/components/PageEditor.tsx
  49. 5 3
      packages/app/src/components/PageEditor/ConflictDiffModal.tsx
  50. 7 4
      packages/app/src/components/PageEditorByHackmd.tsx
  51. 1 1
      packages/app/src/components/PageHistory/Revision.tsx
  52. 5 1
      packages/app/src/components/PageManagement/ApiErrorMessage.jsx
  53. 1 1
      packages/app/src/components/PageStatusAlert.jsx
  54. 9 3
      packages/app/src/components/PutbackPageModal.jsx
  55. 5 5
      packages/app/src/components/ShareLink/ShareLinkList.tsx
  56. 7 2
      packages/app/src/components/TableOfContents.tsx
  57. 1 1
      packages/app/src/components/User/UserInfo.tsx
  58. 0 30
      packages/app/src/components/User/Username.jsx
  59. 26 0
      packages/app/src/components/User/Username.tsx
  60. 2 4
      packages/app/src/pages/[[...path]].page.tsx
  61. 4 2
      packages/app/src/pages/login.page.tsx
  62. 4 0
      packages/app/src/server/routes/page.js
  63. 16 4
      packages/app/src/server/service/page.ts
  64. 13 25
      packages/app/src/stores/context.tsx
  65. 21 0
      packages/app/src/stores/hackmd.ts
  66. 10 7
      packages/app/src/stores/ui.tsx
  67. 5 0
      packages/app/src/styles/bootstrap/_override.scss
  68. 1 1
      packages/app/test/cypress/integration/20-basic-features/access-to-pagelist.spec.ts
  69. 7 6
      packages/app/test/cypress/integration/20-basic-features/click-page-icons.spec.ts
  70. 20 15
      packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts
  71. 2 5
      packages/app/test/cypress/integration/21-basic-features-for-guest/access-to-page.spec.ts
  72. 11 11
      packages/app/test/cypress/integration/50-sidebar/access-to-side-bar.spec.ts
  73. 1 1
      packages/app/test/cypress/support/commands.ts
  74. 1 1
      packages/codemirror-textlint/package.json
  75. 1 1
      packages/core/package.json
  76. 8 0
      packages/core/src/interfaces/user.ts
  77. 1 1
      packages/hackmd/package.json
  78. 1 1
      packages/plugin-attachment-refs/package.json
  79. 4 4
      packages/plugin-lsx/package.json
  80. 1 1
      packages/remark-growi-plugin/package.json
  81. 1 1
      packages/slack/package.json
  82. 2 2
      packages/slackbot-proxy/package.json
  83. 2 2
      packages/ui/package.json
  84. 0 104
      packages/ui/src/components/User/UserPicture.jsx
  85. 120 0
      packages/ui/src/components/User/UserPicture.tsx

+ 14 - 1
CHANGELOG.md

@@ -1,9 +1,22 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.1.5...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.1.7...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.*
 
 
+## [v5.1.7](https://github.com/weseek/growi/compare/v5.1.6...v5.1.7) - 2022-10-26
+
+### 🐛 Bug Fixes
+
+- fix: Page move event notification message (#6823) @hakumizuki
+
+## [v5.1.6](https://github.com/weseek/growi/compare/v5.1.5...v5.1.6) - 2022-10-19
+
+### 🐛 Bug Fixes
+
+- fix: image not showing and exceed crop modal area (#6712) @mudana-grune
+- fix: Conflict Diff Modal Error getCurrentOptionsToSave is not a function (#6745) @kaoritokashiki
+
 ## [v5.1.5](https://github.com/weseek/growi/compare/v5.1.4...v5.1.5) - 2022-10-04
 ## [v5.1.5](https://github.com/weseek/growi/compare/v5.1.4...v5.1.5) - 2022-10-04
 
 
 ### 💎 Features
 ### 💎 Features

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

+ 2 - 10
packages/app/_obsolete/src/client/services/PageContainer.js

@@ -14,8 +14,6 @@ import {
 import {
 import {
   DrawioInterceptor,
   DrawioInterceptor,
 } from '../../services/renderer/interceptor/drawio-interceptor';
 } from '../../services/renderer/interceptor/drawio-interceptor';
-import { toastError } from '../util/apiNotification';
-import { apiPost } from '../util/apiv1-client';
 
 
 const { isTrashPage } = pagePathUtils;
 const { isTrashPage } = pagePathUtils;
 
 
@@ -338,23 +336,17 @@ export default class PageContainer extends Container {
   retrieveMyBookmarkList() {
   retrieveMyBookmarkList() {
   }
   }
 
 
-  async resolveConflict(markdown, editorMode) {
+  async resolveConflict(markdown, editorMode, optionsToSave) {
 
 
     const { pageId, remoteRevisionId, path } = this.state;
     const { pageId, remoteRevisionId, path } = this.state;
     const editorContainer = this.appContainer.getContainer('EditorContainer');
     const editorContainer = this.appContainer.getContainer('EditorContainer');
-    const options = editorContainer.getCurrentOptionsToSave();
-    const optionsToSave = Object.assign({}, options);
 
 
     const res = await this.updatePage(pageId, remoteRevisionId, markdown, optionsToSave);
     const res = await this.updatePage(pageId, remoteRevisionId, markdown, optionsToSave);
 
 
     editorContainer.clearDraft(path);
     editorContainer.clearDraft(path);
     this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
     this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
 
 
-    // Update PageEditor component
-    if (editorMode !== EditorMode.Editor) {
-      // eslint-disable-next-line no-undef
-      globalEmitter.emit('updateEditorValue', markdown);
-    }
+    window.globalEmitter.emit('updateEditorValue', markdown);
 
 
     editorContainer.setState({ tags: res.tags });
     editorContainer.setState({ tags: res.tags });
 
 

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

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

+ 8 - 8
packages/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "6.0.0-RC.7",
+  "version": "6.0.0-RC.8",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
@@ -65,12 +65,12 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^6.0.0-RC.7",
-    "@growi/core": "^6.0.0-RC.7",
-    "@growi/hackmd": "^6.0.0-RC.7",
-    "@growi/plugin-attachment-refs": "^6.0.0-RC.7",
-    "@growi/plugin-lsx": "^6.0.0-RC.7",
-    "@growi/slack": "^6.0.0-RC.7",
+    "@growi/codemirror-textlint": "^6.0.0-RC.8",
+    "@growi/core": "^6.0.0-RC.8",
+    "@growi/hackmd": "^6.0.0-RC.8",
+    "@growi/plugin-attachment-refs": "^6.0.0-RC.8",
+    "@growi/plugin-lsx": "^6.0.0-RC.8",
+    "@growi/slack": "^6.0.0-RC.8",
     "@promster/express": "^7.0.2",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
     "@slack/events-api": "^3.0.0",
@@ -205,7 +205,7 @@
     "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/ui": "^6.0.0-RC.7",
+    "@growi/ui": "^6.0.0-RC.8",
     "@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": "^12.2.3",
     "@next/bundle-analyzer": "^12.2.3",

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

@@ -3,8 +3,14 @@
     "display_name": "English"
     "display_name": "English"
   },
   },
   "wiki_management_home_page": "Wiki Management Home Page",
   "wiki_management_home_page": "Wiki Management Home Page",
+  "last_login": "Last login",
+  "anyone_with_the_link": "anyone with the link",
+  "only_me": "only me",
+  "only_inside_the_group": "only inside the group",
   "security_settings": {
   "security_settings": {
     "security_settings": "Security Settings",
     "security_settings": "Security Settings",
+    "scope_of_page_disclosure": "Scope of page disclosure",
+    "set_point": "Set point",
     "Guest Users Access": "Guest users access",
     "Guest Users Access": "Guest users access",
     "always_hidden": "Always hidden",
     "always_hidden": "Always hidden",
     "always_displayed": "Always displayed",
     "always_displayed": "Always displayed",
@@ -73,6 +79,13 @@
       "restricted": "Restricted (Requires approval by administrators)",
       "restricted": "Restricted (Requires approval by administrators)",
       "closed": "Closed (Invitation Only)"
       "closed": "Closed (Invitation Only)"
     },
     },
+    "share_link_management": "Share Link Management",
+    "share_link_notice":"remove all share links",
+    "delete_all_share_links":"Delete all share links",
+    "Share Link": "Share Link",
+    "Page Path": "Page Path",
+    "expire": "Expiration",
+    "description": "Description",
     "share_link_rights": "Share link rights",
     "share_link_rights": "Share link rights",
     "enable_link_sharing": "Enable link sharing",
     "enable_link_sharing": "Enable link sharing",
     "all_share_links": "All share links",
     "all_share_links": "All share links",

+ 2 - 5
packages/app/public/static/locales/en_US/translation.json

@@ -78,7 +78,6 @@
   "username": "Username",
   "username": "Username",
   "Created": "Created",
   "Created": "Created",
   "Last updated": "Updated",
   "Last updated": "Updated",
-  "last_login": "Last login",
   "Share": "Share",
   "Share": "Share",
   "Markdown Link": "Markdown Link",
   "Markdown Link": "Markdown Link",
   "Create/Edit Template": "Create/Edit template page",
   "Create/Edit Template": "Create/Edit template page",
@@ -129,8 +128,6 @@
   "Only me": "Only me",
   "Only me": "Only me",
   "Only inside the group": "Only inside the group",
   "Only inside the group": "Only inside the group",
   "page_list": "Page List",
   "page_list": "Page List",
-  "scope_of_page_disclosure": "Scope of page disclosure",
-  "set_point": "Set point",
   "Reselect the group": "Reselect the group",
   "Reselect the group": "Reselect the group",
   "Shareable link": "Shareable link",
   "Shareable link": "Shareable link",
   "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
   "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
@@ -243,7 +240,6 @@
     "No_share_links":"No share links",
     "No_share_links":"No share links",
     "Share Link": "Share Link",
     "Share Link": "Share Link",
     "Page Path": "Page Path",
     "Page Path": "Page Path",
-    "share_link_notice":"remove all share links",
     "delete_all_share_links":"Delete all share links",
     "delete_all_share_links":"Delete all share links",
     "expire": "Expiration",
     "expire": "Expiration",
     "Days": "Days",
     "Days": "Days",
@@ -389,7 +385,8 @@
     "notfound_or_forbidden": "Original page is not found or forbidden.",
     "notfound_or_forbidden": "Original page is not found or forbidden.",
     "already_exists": "Page with the path already exists.",
     "already_exists": "Page with the path already exists.",
     "outdated": "Page is updated someone and now outdated.",
     "outdated": "Page is updated someone and now outdated.",
-    "user_not_admin": "Only admin user can delete"
+    "user_not_admin": "Only admin user can delete",
+    "single_deletion_empty_pages": "Empty pages cannot be single deleted"
   },
   },
   "page_history": {
   "page_history": {
     "revision_list": "Revision list",
     "revision_list": "Revision list",

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

@@ -86,6 +86,13 @@
       "restricted": "制限 (登録完了には管理者の承認が必要)",
       "restricted": "制限 (登録完了には管理者の承認が必要)",
       "closed": "非公開 (登録には管理者による招待が必要)"
       "closed": "非公開 (登録には管理者による招待が必要)"
     },
     },
+    "share_link_management": "共有リンク管理",
+    "share_link_notice":"共有リンクを全て削除します",
+    "delete_all_share_links":"全ての共有リンクを削除します",
+    "Share Link": "共有用リンク",
+    "Page Path": "ページパス",
+    "expire": "有効期限",
+    "description": "概要",
     "share_link_rights": "シェアリンクの権限",
     "share_link_rights": "シェアリンクの権限",
     "enable_link_sharing": "リンクのシェアを許可",
     "enable_link_sharing": "リンクのシェアを許可",
     "all_share_links": "全てのシェアリンク",
     "all_share_links": "全てのシェアリンク",

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

@@ -236,7 +236,6 @@
     "No_share_links":"共有リンクが存在しません",
     "No_share_links":"共有リンクが存在しません",
     "Share Link": "共有用リンク",
     "Share Link": "共有用リンク",
     "Page Path": "ページパス",
     "Page Path": "ページパス",
-    "share_link_notice":"共有リンクを全て削除します",
     "delete_all_share_links":"全ての共有リンクを削除します",
     "delete_all_share_links":"全ての共有リンクを削除します",
     "expire": "有効期限",
     "expire": "有効期限",
     "Days": "日間",
     "Days": "日間",
@@ -380,7 +379,8 @@
     "notfound_or_forbidden": "元のページが見つからないか、アクセス権がありません。",
     "notfound_or_forbidden": "元のページが見つからないか、アクセス権がありません。",
     "already_exists": "そのパスを持つページは既に存在しています。",
     "already_exists": "そのパスを持つページは既に存在しています。",
     "outdated": "ページが他のユーザーによって更新されました。",
     "outdated": "ページが他のユーザーによって更新されました。",
-    "user_not_admin": "権限のあるユーザーのみが削除できます"
+    "user_not_admin": "権限のあるユーザーのみが削除できます",
+    "single_deletion_empty_pages": "空ページの単体削除はできません"
   },
   },
   "page_history": {
   "page_history": {
     "revision_list": "更新履歴",
     "revision_list": "更新履歴",

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

@@ -9,6 +9,7 @@
   "Created": "创建",
   "Created": "创建",
   "Edit": "编辑",
   "Edit": "编辑",
   "Description": "描述",
   "Description": "描述",
+  "last_login": "上次登录",
   "wiki_management_home_page": "Wiki管理首页",
   "wiki_management_home_page": "Wiki管理首页",
   "public": "公共",
   "public": "公共",
   "anyone_with_the_link": "任何人",
   "anyone_with_the_link": "任何人",
@@ -87,6 +88,13 @@
 			"restricted": "受限(需要管理员批准)",
 			"restricted": "受限(需要管理员批准)",
 			"closed": "已关闭(仅限邀请)"
 			"closed": "已关闭(仅限邀请)"
 		},
 		},
+    "share_link_management": "Share Link Management",
+    "share_link_notice":"remove all share links",
+    "delete_all_share_links":"Delete all share links",
+    "Share Link": "Share Link",
+    "Page Path": "Page Path",
+    "expire": "Expiration",
+    "description": "Description",
     "share_link_rights": "分享链接权",
     "share_link_rights": "分享链接权",
     "enable_link_sharing": "启用链接共享",
     "enable_link_sharing": "启用链接共享",
     "all_share_links": "所有共享链接",
     "all_share_links": "所有共享链接",

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

@@ -73,7 +73,6 @@
   "username": "用户名",
   "username": "用户名",
 	"Created": "创建",
 	"Created": "创建",
 	"Last updated": "上次更新",
 	"Last updated": "上次更新",
-  "last_login": "上次登录",
 	"Share": "分享",
 	"Share": "分享",
   "Share Link": "分享链接",
   "Share Link": "分享链接",
 	"Markdown Link": "Markdown链接",
 	"Markdown Link": "Markdown链接",
@@ -362,7 +361,8 @@
 		"notfound_or_forbidden": "未找到或禁止原始页。",
 		"notfound_or_forbidden": "未找到或禁止原始页。",
 		"already_exists": "具有该路径的页面已存在",
 		"already_exists": "具有该路径的页面已存在",
 		"outdated": "页面已被某人更新,现在已过时。",
 		"outdated": "页面已被某人更新,现在已过时。",
-		"user_not_admin": "仅管理员用户可以删除"
+		"user_not_admin": "仅管理员用户可以删除",
+    "single_deletion_empty_pages": "空的页面不能被单一删除"
   },
   },
   "page_history": {
   "page_history": {
     "revision_list": "修订清单",
     "revision_list": "修订清单",
@@ -498,7 +498,7 @@
     "file_upload_succeeded": "文件上传成功",
     "file_upload_succeeded": "文件上传成功",
     "file_upload_failed": "文件上传失败",
     "file_upload_failed": "文件上传失败",
     "initialize_successed": "Succeeded to initialize {{target}}",
     "initialize_successed": "Succeeded to initialize {{target}}",
-		"give_user_admin": "Succeeded to give {{username}} admin",
+    "give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin ",
     "remove_user_admin": "Succeeded to remove {{username}} admin ",
 		"activate_user_success": "Succeeded to activating {{username}}",
 		"activate_user_success": "Succeeded to activating {{username}}",
 		"deactivate_user_success": "Succeeded to deactivate {{username}}",
 		"deactivate_user_success": "Succeeded to deactivate {{username}}",
@@ -589,7 +589,6 @@
     "No_share_links":"No share links",
     "No_share_links":"No share links",
     "Share Link": "Share Link",
     "Share Link": "Share Link",
     "Page Path": "Page Path",
     "Page Path": "Page Path",
-    "share_link_notice":"remove all share links",
     "delete_all_share_links":"Delete all share links",
     "delete_all_share_links":"Delete all share links",
     "expire": "Expiration",
     "expire": "Expiration",
     "Days": "Days",
     "Days": "Days",

+ 1 - 1
packages/app/src/client/util/editor.ts

@@ -1,4 +1,4 @@
-import { OptionsToSave } from '~/interfaces/editor-settings';
+import type { OptionsToSave } from '~/interfaces/editor-settings';
 
 
 export const getOptionsToSave = (
 export const getOptionsToSave = (
     isSlackEnabled: boolean,
     isSlackEnabled: boolean,

+ 1 - 1
packages/app/src/components/Admin/AuditLog/ActivityTable.tsx

@@ -47,7 +47,7 @@ export const ActivityTable : FC<Props> = (props: Props) => {
                 <td>
                 <td>
                   { activity.user != null && (
                   { activity.user != null && (
                     <>
                     <>
-                      <UserPicture user={activity.user} className="picture rounded-circle" />
+                      <UserPicture user={activity.user} />
                       <a className="ml-2" href={pagePathUtils.userPageRoot(activity.user)}>{activity.snapshot?.username}</a>
                       <a className="ml-2" href={pagePathUtils.userPageRoot(activity.user)}>{activity.snapshot?.username}</a>
                     </>
                     </>
                   )}
                   )}

+ 0 - 91
packages/app/src/components/Admin/ManageExternalAccount.jsx

@@ -1,91 +0,0 @@
-import React, { Fragment } from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
-import { toastError } from '~/client/util/apiNotification';
-
-import PaginationWrapper from '../PaginationWrapper';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-import ExternalAccountTable from './Users/ExternalAccountTable';
-
-
-class ManageExternalAccount extends React.Component {
-
-  constructor(props) {
-    super(props);
-    this.handleExternalAccountPage = this.handleExternalAccountPage.bind(this);
-  }
-
-  UNSAFE_componentWillMount() {
-    this.handleExternalAccountPage(1);
-  }
-
-  async handleExternalAccountPage(selectedPage) {
-    try {
-      await this.props.adminExternalAccountsContainer.retrieveExternalAccountsByPagingNum(selectedPage);
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { t, adminExternalAccountsContainer } = this.props;
-    const { activePage, totalAccounts, pagingLimit } = adminExternalAccountsContainer.state;
-
-
-    const pager = (
-      <PaginationWrapper
-        activePage={activePage}
-        changePage={this.handleExternalAccountPage}
-        totalItemsCount={totalAccounts}
-        pagingLimit={pagingLimit}
-        align="center"
-        size="sm"
-      />
-    );
-    return (
-      <Fragment>
-        <p>
-          <a className="btn btn-outline-secondary" href="/admin/users">
-            <i className="icon-fw ti-arrow-left" aria-hidden="true"></i>
-            {t('admin:user_management.back_to_user_management')}
-          </a>
-        </p>
-
-        <h2>{t('admin:user_management.external_account_list')}</h2>
-        {(totalAccounts !== 0) ? (
-          <>
-            {pager}
-            <ExternalAccountTable />
-            {pager}
-          </>
-        )
-          : (
-            <>
-              {t('admin:user_management.external_account_none')}
-            </>
-          )}
-
-      </Fragment>
-    );
-  }
-
-}
-
-ManageExternalAccount.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminExternalAccountsContainer: PropTypes.instanceOf(AdminExternalAccountsContainer).isRequired,
-};
-
-const ManageExternalAccountWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <ManageExternalAccount t={t} {...props} />;
-};
-
-const ManageExternalAccountWrapper = withUnstatedContainers(ManageExternalAccountWrapperFC, [AdminExternalAccountsContainer]);
-
-export default ManageExternalAccountWrapper;

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

@@ -0,0 +1,79 @@
+import React, { useCallback, useEffect } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
+
+import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
+import { toastError } from '~/client/util/apiNotification';
+
+import PaginationWrapper from '../PaginationWrapper';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import ExternalAccountTable from './Users/ExternalAccountTable';
+
+type ManageExternalAccountProps = {
+  adminExternalAccountsContainer: AdminExternalAccountsContainer,
+}
+
+const ManageExternalAccount = (props: ManageExternalAccountProps): JSX.Element => {
+
+  const { t } = useTranslation();
+  const { adminExternalAccountsContainer } = props;
+  const { activePage, totalAccounts, pagingLimit } = adminExternalAccountsContainer.state;
+
+  const externalAccountPageHandler = useCallback(async(selectedPage) => {
+    try {
+      await adminExternalAccountsContainer.retrieveExternalAccountsByPagingNum(selectedPage);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminExternalAccountsContainer]);
+
+  // for Next routing
+  useEffect(() => {
+    externalAccountPageHandler(1);
+  }, [externalAccountPageHandler]);
+
+  const pager = (
+    <PaginationWrapper
+      activePage={activePage}
+      changePage={externalAccountPageHandler}
+      totalItemsCount={totalAccounts}
+      pagingLimit={pagingLimit}
+      align="center"
+      size="sm"
+    />
+  );
+
+  return (
+    <>
+      <p>
+        <Link href="/admin/users" prefetch={false}>
+          <a className="btn btn-outline-secondary">
+            <i className="icon-fw ti ti-arrow-left" aria-hidden="true"></i>
+            {t('admin:user_management.back_to_user_management')}
+          </a>
+        </Link>
+      </p>
+      <h2>{t('admin:user_management.external_account_list')}</h2>
+      {(totalAccounts !== 0) ? (
+        <>
+          {pager}
+          <ExternalAccountTable />
+          {pager}
+        </>
+      )
+        : (
+          <>
+            { t('admin:user_management.external_account_none') }
+          </>
+        )
+      }
+    </>
+  );
+};
+
+const ManageExternalAccountWrapper = withUnstatedContainers(ManageExternalAccount, [AdminExternalAccountsContainer]);
+
+export default ManageExternalAccountWrapper;

+ 4 - 4
packages/app/src/components/Admin/Security/DeleteAllShareLinksModal.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import React from 'react';
 
 
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 import {
 import {
   Button, Modal, ModalHeader, ModalBody, ModalFooter,
   Button, Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
@@ -36,11 +36,11 @@ const DeleteAllShareLinksModal = React.memo((props) => {
       <ModalHeader tag="h4" toggle={closeButtonHandler} className="bg-danger text-light">
       <ModalHeader tag="h4" toggle={closeButtonHandler} className="bg-danger text-light">
         <span>
         <span>
           <i className="icon-fw icon-fire"></i>
           <i className="icon-fw icon-fire"></i>
-          {t('share_links.delete_all_share_links')}
+          {t('security_settings.delete_all_share_links')}
         </span>
         </span>
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
-        { t('share_links.share_link_notice')}
+        { t('security_settings.share_link_notice')}
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
         <Button onClick={closeButtonHandler}>{t('Cancel')}</Button>
         <Button onClick={closeButtonHandler}>{t('Cancel')}</Button>
@@ -66,7 +66,7 @@ DeleteAllShareLinksModal.propTypes = {
 
 
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 const DeleteAllShareLinksModalWrapperFC = (props) => {
 const DeleteAllShareLinksModalWrapperFC = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
 
   return <DeleteAllShareLinksModal t={t} {...props} />;
   return <DeleteAllShareLinksModal t={t} {...props} />;
 };
 };

+ 6 - 3
packages/app/src/components/Admin/Security/SecurityManagementContents.jsx

@@ -1,6 +1,7 @@
 import React, { useMemo, useState } from 'react';
 import React, { useMemo, useState } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 import { TabContent, TabPane } from 'reactstrap';
 import { TabContent, TabPane } from 'reactstrap';
 
 
 import CustomNav from '../../CustomNavigation/CustomNav';
 import CustomNav from '../../CustomNavigation/CustomNav';
@@ -95,9 +96,11 @@ const SecurityManagementContents = () => {
       <div className="mb-5">
       <div className="mb-5">
         <h2 className="border-bottom">{t('security_settings.xss_prevent_setting')}</h2>
         <h2 className="border-bottom">{t('security_settings.xss_prevent_setting')}</h2>
         <div className="text-center">
         <div className="text-center">
-          <a style={{ fontSize: 'large' }} href="/admin/markdown/#preventXSS">
-            <i className="fa-fw icon-login"></i> {t('security_settings.xss_prevent_setting_link')}
-          </a>
+          <Link href="/admin/markdown/#preventXSS" prefetch={false}>
+            <a style={{ fontSize: 'large' }}>
+              <i className="fa-fw icon-login"></i> {t('security_settings.xss_prevent_setting_link')}
+            </a>
+          </Link>
         </div>
         </div>
       </div>
       </div>
 
 

+ 0 - 208
packages/app/src/components/Admin/Security/ShareLinkSetting.jsx

@@ -1,208 +0,0 @@
-import React, { Fragment } from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { apiv3Delete } from '~/client/util/apiv3-client';
-
-import PaginationWrapper from '../../PaginationWrapper';
-import ShareLinkList from '../../ShareLink/ShareLinkList';
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-
-import DeleteAllShareLinksModal from './DeleteAllShareLinksModal';
-
-const Pager = (props) => {
-  if (props.links.length === 0) {
-    return null;
-  }
-  return (
-    <PaginationWrapper
-      activePage={props.activePage}
-      changePage={props.handlePage}
-      totalItemsCount={props.totalLinks}
-      pagingLimit={props.limit}
-      align="center"
-      size="sm"
-    />
-  );
-};
-
-Pager.propTypes = {
-  links: PropTypes.array.isRequired,
-  activePage: PropTypes.number.isRequired,
-  handlePage: PropTypes.func.isRequired,
-  totalLinks: PropTypes.number.isRequired,
-  limit: PropTypes.number.isRequired,
-};
-
-class ShareLinkSetting extends React.Component {
-
-  constructor() {
-    super();
-
-    this.state = {
-      isDeleteConfirmModalShown: false,
-    };
-    this.getShareLinkList = this.getShareLinkList.bind(this);
-    this.showDeleteConfirmModal = this.showDeleteConfirmModal.bind(this);
-    this.closeDeleteConfirmModal = this.closeDeleteConfirmModal.bind(this);
-    this.deleteAllLinksButtonHandler = this.deleteAllLinksButtonHandler.bind(this);
-    this.deleteLinkById = this.deleteLinkById.bind(this);
-    this.switchDisableLinkSharing = this.switchDisableLinkSharing.bind(this);
-  }
-
-  UNSAFE_componentWillMount() {
-    this.getShareLinkList(1);
-  }
-
-  async getShareLinkList(page) {
-    try {
-      await this.props.adminGeneralSecurityContainer.retrieveShareLinksByPagingNum(page);
-    }
-    catch (err) {
-      toastError(err);
-    }
-
-  }
-
-  showDeleteConfirmModal() {
-    this.setState({ isDeleteConfirmModalShown: true });
-  }
-
-  closeDeleteConfirmModal() {
-    this.setState({ isDeleteConfirmModalShown: false });
-  }
-
-  async deleteAllLinksButtonHandler() {
-    const { t } = this.props;
-
-    try {
-      const res = await apiv3Delete('/share-links/all');
-      const { deletedCount } = res.data;
-      toastSuccess(t('toaster.remove_share_link', { count: deletedCount }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-    this.getShareLinkList(1);
-  }
-
-  async deleteLinkById(shareLinkId) {
-    const { t, adminGeneralSecurityContainer } = this.props;
-    const { shareLinksActivePage } = adminGeneralSecurityContainer.state;
-
-    try {
-      const res = await apiv3Delete(`/share-links/${shareLinkId}`);
-      const { deletedShareLink } = res.data;
-      toastSuccess(t('toaster.remove_share_link_success', { shareLinkId: deletedShareLink._id }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-
-    this.getShareLinkList(shareLinksActivePage);
-  }
-
-  async switchDisableLinkSharing() {
-    const { t, adminGeneralSecurityContainer } = this.props;
-    try {
-      await adminGeneralSecurityContainer.switchDisableLinkSharing();
-      toastSuccess(t('toaster.switch_disable_link_sharing_success'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-
-  render() {
-    const { t, adminGeneralSecurityContainer } = this.props;
-    const {
-      shareLinks, shareLinksActivePage, totalshareLinks, shareLinksPagingLimit, disableLinkSharing,
-    } = adminGeneralSecurityContainer.state;
-
-    return (
-      <Fragment>
-        <div className="mb-3">
-          <button
-            className="pull-right btn btn-danger"
-            disabled={shareLinks.length === 0}
-            type="button"
-            onClick={this.showDeleteConfirmModal}
-          >
-            {t('share_links.delete_all_share_links')}
-          </button>
-          <h2 className="alert-anchor border-bottom">{t('share_links.share_link_management')}</h2>
-        </div>
-        <h4>{t('security_settings.share_link_rights')}</h4>
-        <div className="row mb-5">
-          <div className="col-6 offset-3">
-            <div className="custom-control custom-switch custom-checkbox-success">
-              <input
-                type="checkbox"
-                className="custom-control-input"
-                id="disableLinkSharing"
-                checked={!disableLinkSharing}
-                onChange={() => this.switchDisableLinkSharing()}
-              />
-              <label className="custom-control-label" htmlFor="disableLinkSharing">
-                {t('security_settings.enable_link_sharing')}
-              </label>
-            </div>
-            {!adminGeneralSecurityContainer.state.setupStrategies.includes('local') && disableLinkSharing && (
-              <div className="badge badge-warning">{t('security_settings.setup_is_not_yet_complete')}</div>
-            )}
-          </div>
-        </div>
-        <h4>{t('security_settings.all_share_links')}</h4>
-        <Pager
-          links={shareLinks}
-          activePage={shareLinksActivePage}
-          handlePage={this.getShareLinkList}
-          totalLinks={totalshareLinks}
-          limit={shareLinksPagingLimit}
-        />
-
-        {(shareLinks.length !== 0) ? (
-          <ShareLinkList
-            shareLinks={shareLinks}
-            onClickDeleteButton={this.deleteLinkById}
-            isAdmin
-          />
-        )
-          : (<p className="text-center">{t('share_links.No_share_links')}</p>
-          )
-        }
-
-
-        <DeleteAllShareLinksModal
-          isOpen={this.state.isDeleteConfirmModalShown}
-          onClose={this.closeDeleteConfirmModal}
-          onClickDeleteButton={this.deleteAllLinksButtonHandler}
-        />
-
-      </Fragment>
-    );
-  }
-
-}
-
-ShareLinkSetting.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
-};
-
-const ShareLinkSettingWrapperFC = (props) => {
-  const { t } = useTranslation('admin');
-  return <ShareLinkSetting t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const ShareLinkSettingWrapper = withUnstatedContainers(ShareLinkSettingWrapperFC, [AdminGeneralSecurityContainer]);
-
-export default ShareLinkSettingWrapper;

+ 170 - 0
packages/app/src/components/Admin/Security/ShareLinkSetting.tsx

@@ -0,0 +1,170 @@
+import React, {
+  useCallback, useEffect, useState,
+} from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Delete } from '~/client/util/apiv3-client';
+
+import PaginationWrapper from '../../PaginationWrapper';
+import ShareLinkList from '../../ShareLink/ShareLinkList';
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+import DeleteAllShareLinksModal from './DeleteAllShareLinksModal';
+
+type PagerProps = {
+  activePage: number,
+  pagingHandler: (page: number) => Promise<void>,
+  totalLinks: number,
+  limit: number,
+}
+
+type ShareLinkSettingProps = {
+  adminGeneralSecurityContainer: AdminGeneralSecurityContainer,
+}
+
+const Pager = (props: PagerProps) => {
+  const {
+    activePage, pagingHandler, totalLinks, limit,
+  } = props;
+
+  return (
+    <PaginationWrapper
+      activePage={activePage}
+      changePage={pagingHandler}
+      totalItemsCount={totalLinks}
+      pagingLimit={limit}
+      align="center"
+      size="sm"
+    />
+  );
+};
+
+const ShareLinkSetting = (props: ShareLinkSettingProps) => {
+
+  const { t } = useTranslation('admin');
+  const { adminGeneralSecurityContainer } = props;
+  const {
+    shareLinks, shareLinksActivePage, totalshareLinks, shareLinksPagingLimit,
+    disableLinkSharing, setupStrategies,
+  } = adminGeneralSecurityContainer.state;
+  const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState<boolean>();
+
+  const getShareLinkList = useCallback(async(page: number) => {
+    try {
+      await adminGeneralSecurityContainer.retrieveShareLinksByPagingNum(page);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminGeneralSecurityContainer]);
+
+  // for Next routing
+  useEffect(() => {
+    getShareLinkList(1);
+  }, [getShareLinkList]);
+
+  const deleteAllLinksButtonHandler = useCallback(async() => {
+    try {
+      const res = await apiv3Delete('/share-links/all');
+      const { deletedCount } = res.data;
+      toastSuccess(t('toaster.remove_share_link', { count: deletedCount }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+    getShareLinkList(1);
+  }, [getShareLinkList, t]);
+
+  const deleteLinkById = useCallback(async(shareLinkId: string) => {
+    try {
+      const res = await apiv3Delete(`/share-links/${shareLinkId}`);
+      const { deletedShareLink } = res.data;
+      toastSuccess(t('toaster.remove_share_link_success', { shareLinkId: deletedShareLink._id }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+    getShareLinkList(shareLinksActivePage);
+  }, [shareLinksActivePage, getShareLinkList, t]);
+
+  const switchDisableLinkSharing = useCallback(async() => {
+    try {
+      await adminGeneralSecurityContainer.switchDisableLinkSharing();
+      toastSuccess(t('toaster.switch_disable_link_sharing_success'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminGeneralSecurityContainer, t]);
+
+  return (
+    <>
+      <div className="mb-3">
+        <button
+          className="pull-right btn btn-danger"
+          disabled={shareLinks.length === 0}
+          type="button"
+          onClick={() => setIsDeleteConfirmModalShown(true)}
+        >
+          {t('security_settings.delete_all_share_links')}
+        </button>
+        <h2 className="alert-anchor border-bottom">{t('security_settings.share_link_management')}</h2>
+      </div>
+      <h4>{t('security_settings.share_link_rights')}</h4>
+      <div className="row mb-5">
+        <div className="col-6 offset-3">
+          <div className="custom-control custom-switch custom-checkbox-success">
+            <input
+              type="checkbox"
+              className="custom-control-input"
+              id="disableLinkSharing"
+              checked={!disableLinkSharing}
+              onChange={() => switchDisableLinkSharing()}
+            />
+            <label className="custom-control-label" htmlFor="disableLinkSharing">
+              {t('security_settings.enable_link_sharing')}
+            </label>
+          </div>
+          {!setupStrategies.includes('local') && disableLinkSharing && (
+            <div className="badge badge-warning">{t('security_settings.setup_is_not_yet_complete')}</div>
+          )}
+        </div>
+      </div>
+      <h4>{t('security_settings.all_share_links')}</h4>
+      <Pager
+        activePage={shareLinksActivePage}
+        pagingHandler={getShareLinkList}
+        totalLinks={totalshareLinks}
+        limit={shareLinksPagingLimit}
+      />
+
+      {(shareLinks.length !== 0) ? (
+        <ShareLinkList
+          shareLinks={shareLinks}
+          onClickDeleteButton={deleteLinkById}
+          isAdmin
+        />
+      )
+        : (<p className="text-center">{t('share_links.No_share_links')}</p>
+        )
+      }
+
+      <DeleteAllShareLinksModal
+        isOpen={isDeleteConfirmModalShown}
+        onClose={() => setIsDeleteConfirmModalShown(false)}
+        onClickDeleteButton={deleteAllLinksButtonHandler}
+      />
+
+    </>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ShareLinkSettingWrapper = withUnstatedContainers(ShareLinkSetting, [AdminGeneralSecurityContainer]);
+
+export default ShareLinkSettingWrapper;

+ 1 - 1
packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -2,11 +2,11 @@ import React, {
   FC, useState, useEffect,
   FC, useState, useEffect,
 } from 'react';
 } from 'react';
 
 
+import type { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '@growi/core';
 import dateFnsFormat from 'date-fns/format';
 import dateFnsFormat from 'date-fns/format';
 import { TFunctionResult } from 'i18next';
 import { TFunctionResult } from 'i18next';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
-import { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '~/interfaces/user';
 
 
 type Props = {
 type Props = {
   headerLabel?: TFunctionResult,
   headerLabel?: TFunctionResult,

+ 1 - 1
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx

@@ -44,7 +44,7 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
           return (
           return (
             <tr key={relation._id}>
             <tr key={relation._id}>
               <td>
               <td>
-                <UserPicture user={relatedUser} className="picture rounded-circle" />
+                <UserPicture user={relatedUser} />
               </td>
               </td>
               <td>
               <td>
                 <strong>{relatedUser.username}</strong>
                 <strong>{relatedUser.username}</strong>

+ 0 - 235
packages/app/src/components/Admin/UserManagement.jsx

@@ -1,235 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-import { toastError } from '~/client/util/apiNotification';
-
-import PaginationWrapper from '../PaginationWrapper';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-
-import InviteUserControl from './Users/InviteUserControl';
-import PasswordResetModal from './Users/PasswordResetModal';
-import UserTable from './Users/UserTable';
-
-import styles from './UserManagement.module.scss';
-
-class UserManagement extends React.Component {
-
-  constructor(props) {
-    super();
-
-    this.state = {
-      isNotifyCommentShow: false,
-    };
-
-    this.handlePage = this.handlePage.bind(this);
-    this.handleChangeSearchText = this.handleChangeSearchText.bind(this);
-  }
-
-  UNSAFE_componentWillMount() {
-    this.handlePage(1);
-  }
-
-  async handlePage(selectedPage) {
-    try {
-      await this.props.adminUsersContainer.retrieveUsersByPagingNum(selectedPage);
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  /**
-   * For checking same check box twice
-   * @param {string} statusType
-   */
-  async handleClick(statusType) {
-    const { adminUsersContainer } = this.props;
-    if (!this.validateToggleStatus(statusType)) {
-      return this.setState({ isNotifyCommentShow: true });
-    }
-
-    if (this.state.isNotifyCommentShow) {
-      await this.setState({ isNotifyCommentShow: false });
-    }
-    adminUsersContainer.handleClick(statusType);
-  }
-
-  /**
-   * Workaround user status check box
-   * @param {string} statusType
-   */
-  validateToggleStatus(statusType) {
-    if (this.props.adminUsersContainer.isSelected(statusType)) {
-      return this.props.adminUsersContainer.state.selectedStatusList.size > 1;
-    }
-    return true;
-  }
-
-  /**
-   * Reset button
-   */
-  resetButtonClickHandler() {
-    const { adminUsersContainer } = this.props;
-    try {
-      adminUsersContainer.resetAllChanges();
-      this.searchUserElement.value = '';
-      this.setState({ isNotifyCommentShow: false });
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  /**
-   * Workaround increamental search
-   * @param {string} event
-   */
-  handleChangeSearchText(event) {
-    this.props.adminUsersContainer.handleChangeSearchText(event.target.value);
-  }
-
-  renderCheckbox(status, statusLabel, statusColor) {
-    return (
-      <div className={`custom-control custom-checkbox custom-checkbox-${statusColor} mr-2`}>
-        <input
-          className="custom-control-input"
-          type="checkbox"
-          id={`c_${status}`}
-          checked={this.props.adminUsersContainer.isSelected(status)}
-          onChange={() => { this.handleClick(status) }}
-        />
-        <label className="custom-control-label" htmlFor={`c_${status}`}>
-          <span className={`badge badge-pill badge-${statusColor} d-inline-block vt mt-1`}>
-            {statusLabel}
-          </span>
-        </label>
-      </div>
-    );
-  }
-
-  render() {
-    const { t, adminUsersContainer } = this.props;
-
-    const pager = (
-      <div className="my-3">
-        <PaginationWrapper
-          activePage={adminUsersContainer.state.activePage}
-          changePage={this.handlePage}
-          totalItemsCount={adminUsersContainer.state.totalUsers}
-          pagingLimit={adminUsersContainer.state.pagingLimit}
-          align="center"
-          size="sm"
-        />
-      </div>
-    );
-
-    const clearButton = (
-      adminUsersContainer.state.searchText.length > 0
-        ? (
-          <i
-            className={`icon-close ${styles['search-clear']}`}
-            onClick={() => {
-              adminUsersContainer.clearSearchText();
-              this.searchUserElement.value = '';
-            }}
-          />
-        )
-        : ''
-    );
-
-    return (
-      <div data-testid="admin-users">
-        {adminUsersContainer.state.userForPasswordResetModal != null
-        && (
-          <PasswordResetModal
-            isOpen={adminUsersContainer.state.isPasswordResetModalShown}
-            onClose={adminUsersContainer.hidePasswordResetModal}
-            userForPasswordResetModal={adminUsersContainer.state.userForPasswordResetModal}
-          />
-        )}
-        <p>
-          <InviteUserControl />
-          <a className="btn btn-outline-secondary ml-2" href="/admin/users/external-accounts" role="button">
-            <i className="icon-user-follow" aria-hidden="true"></i>
-            {t('admin:user_management.external_account')}
-          </a>
-        </p>
-
-        <h2>{t('user_management.user_management')}</h2>
-        <div className="border-top border-bottom">
-
-          <div className="row d-flex justify-content-start align-items-center my-2">
-            <div className="col-md-3 d-flex align-items-center my-2">
-              <i className="icon-magnifier mr-1"></i>
-              <span className="search-typeahead">
-                <input
-                  className="w-100"
-                  type="text"
-                  ref={(searchUserElement) => { this.searchUserElement = searchUserElement }}
-                  onChange={this.handleChangeSearchText}
-                />
-                { clearButton }
-              </span>
-            </div>
-
-            <div className="offset-md-1 col-md-6 my-2">
-              <div className="form-inline">
-                {this.renderCheckbox('all', 'All', 'secondary')}
-                {this.renderCheckbox('registered', 'Approval Pending', 'info')}
-                {this.renderCheckbox('active', 'Active', 'success')}
-                {this.renderCheckbox('suspended', 'Suspended', 'warning')}
-                {this.renderCheckbox('invited', 'Invited', 'pink')}
-              </div>
-              <div>
-                {
-                  this.state.isNotifyCommentShow
-                  && <span className="text-warning">{t('admin:user_management.click_twice_same_checkbox')}</span>
-                }
-              </div>
-            </div>
-
-            <div className="col-md-2 my-2">
-              <button
-                type="button"
-                className="btn btn-outline-secondary btn-sm"
-                onClick={() => { this.resetButtonClickHandler() }}
-              >
-                <span
-                  className="icon-refresh mr-1"
-                >
-                </span>
-                Reset
-              </button>
-            </div>
-          </div>
-        </div>
-
-
-        {pager}
-        <UserTable />
-        {pager}
-
-      </div>
-    );
-  }
-
-}
-
-
-UserManagement.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
-};
-
-const UserManagementFc = (props) => {
-  const { t } = useTranslation('admin');
-  return <UserManagement t={t} {...props} />;
-};
-
-const UserManagementWrapper = withUnstatedContainers(UserManagementFc, [AdminUsersContainer]);
-
-export default UserManagementWrapper;

+ 43 - 3
packages/app/src/components/Admin/UserManagement.module.scss

@@ -1,5 +1,45 @@
+@use '~/styles/bootstrap/init' as bs;
+
 // styles for admin user search
 // styles for admin user search
-.search-clear :global {
-  top: 90px;
-  right: 4px;
+.search-typeahead :global {
+  position: relative;
+  width: 100%;
+  // corner radius
+  border-top-right-radius: bs.$border-radius;
+  border-bottom-right-radius: bs.$border-radius;
+  .rbt-input-main {
+    padding-right: 36px;
+  }
+  .search-clear {
+    position: absolute;
+    top: 12px;
+    right: 1px;
+    z-index: 3;
+    width: 24px;
+    height: 24px;
+    padding: 0;
+    line-height: 0;
+  }
+
+  .rbt-menu {
+    max-height: none !important;
+    margin-top: 3px;
+
+    li a span {
+      .page-path {
+        display: inline;
+        padding: 0 4px;
+        color: inherit;
+      }
+
+      .page-list-meta {
+        font-size: 0.9em;
+        color: bs.$gray-400;
+
+        > span {
+          margin-right: 0.3rem;
+        }
+      }
+    }
+  }
 }
 }

+ 202 - 0
packages/app/src/components/Admin/UserManagement.tsx

@@ -0,0 +1,202 @@
+import React, {
+  useEffect, useState, useRef, useCallback,
+} from 'react';
+
+import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
+
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+import { toastError } from '~/client/util/apiNotification';
+
+import PaginationWrapper from '../PaginationWrapper';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import InviteUserControl from './Users/InviteUserControl';
+import PasswordResetModal from './Users/PasswordResetModal';
+import UserTable from './Users/UserTable';
+
+import styles from './UserManagement.module.scss';
+
+type UserManagementProps = {
+  adminUsersContainer: AdminUsersContainer
+}
+
+const UserManagement = (props: UserManagementProps) => {
+
+  const { t } = useTranslation('admin');
+  const { adminUsersContainer } = props;
+  const [isNotifyCommentShow, setIsNotifyCommentShow] = useState(false);
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  const pagingHandler = useCallback(async(selectedPage: number) => {
+    try {
+      await adminUsersContainer.retrieveUsersByPagingNum(selectedPage);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminUsersContainer]);
+
+  // for Next routing
+  useEffect(() => {
+    pagingHandler(1);
+  }, [pagingHandler]);
+
+  const validateToggleStatus = (statusType: string) => {
+    return (adminUsersContainer.isSelected(statusType)) ? (
+      adminUsersContainer.state.selectedStatusList.size > 1
+    )
+      : (
+        true
+      );
+  };
+
+  const clickHandler = (statusType: string) => {
+    if (!validateToggleStatus(statusType)) {
+      return setIsNotifyCommentShow(true);
+    }
+
+    if (isNotifyCommentShow) {
+      setIsNotifyCommentShow(false);
+    }
+    adminUsersContainer.handleClick(statusType);
+  };
+
+  const resetButtonClickHandler = useCallback(async() => {
+    try {
+      await adminUsersContainer.resetAllChanges();
+      setIsNotifyCommentShow(false);
+      if (inputRef.current != null) {
+        inputRef.current.value = '';
+      }
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminUsersContainer]);
+
+  const changeSearchTextHandler = useCallback(async(e: React.FormEvent<HTMLInputElement>) => {
+    await adminUsersContainer.handleChangeSearchText(e?.currentTarget.value);
+  }, [adminUsersContainer]);
+
+  const renderCheckbox = (status: string, statusLabel: string, statusColor: string) => {
+    return (
+      <div className={`custom-control custom-checkbox custom-checkbox-${statusColor} mr-2`}>
+        <input
+          className="custom-control-input"
+          type="checkbox"
+          id={`c_${status}`}
+          checked={adminUsersContainer.isSelected(status)}
+          onChange={() => clickHandler(status)}
+        />
+        <label className="custom-control-label" htmlFor={`c_${status}`}>
+          <span className={`badge badge-pill badge-${statusColor} d-inline-block vt mt-1`}>
+            {statusLabel}
+          </span>
+        </label>
+      </div>
+    );
+  };
+
+  const pager = (
+    <div className="my-3">
+      <PaginationWrapper
+        activePage={adminUsersContainer.state.activePage}
+        changePage={pagingHandler}
+        totalItemsCount={adminUsersContainer.state.totalUsers}
+        pagingLimit={adminUsersContainer.state.pagingLimit}
+        align="center"
+        size="sm"
+      />
+    </div>
+  );
+
+  return (
+    <div data-testid="admin-users">
+      { adminUsersContainer.state.userForPasswordResetModal != null
+      && (
+        <PasswordResetModal
+          isOpen={adminUsersContainer.state.isPasswordResetModalShown}
+          onClose={adminUsersContainer.hidePasswordResetModal}
+          userForPasswordResetModal={adminUsersContainer.state.userForPasswordResetModal}
+        />
+      ) }
+      <p>
+        <InviteUserControl />
+        <Link href="/admin/users/external-accounts" prefetch={false}>
+          <a className="btn btn-outline-secondary ml-2" role="button">
+            <i className="icon-user-follow mr-1" aria-hidden="true"></i>
+            {t('admin:user_management.external_account')}
+          </a>
+        </Link>
+      </p>
+
+      <h2>{t('user_management.user_management')}</h2>
+      <div className="border-top border-bottom">
+
+        <div className="row d-flex justify-content-start align-items-center my-2">
+          <div className="col-md-3 d-flex align-items-center my-2">
+            <i className="icon-magnifier mr-1"></i>
+            <span className={`search-typeahead ${styles['search-typeahead']}`}>
+              <input
+                className="w-100"
+                type="text"
+                ref={inputRef}
+                onChange={changeSearchTextHandler}
+              />
+              {
+                adminUsersContainer.state.searchText.length > 0
+                  ? (
+                    <i
+                      className="icon-close search-clear"
+                      onClick={async() => {
+                        await adminUsersContainer.clearSearchText();
+                        if (inputRef.current != null) {
+                          inputRef.current.value = '';
+                        }
+                      }}
+                    />
+                  )
+                  : ''
+              }
+            </span>
+          </div>
+
+          <div className="offset-md-1 col-md-6 my-2">
+            <div className="form-inline">
+              {renderCheckbox('all', 'All', 'secondary')}
+              {renderCheckbox('registered', 'Approval Pending', 'info')}
+              {renderCheckbox('active', 'Active', 'success')}
+              {renderCheckbox('suspended', 'Suspended', 'warning')}
+              {renderCheckbox('invited', 'Invited', 'pink')}
+            </div>
+            <div>
+              { isNotifyCommentShow && <span className="text-warning">{t('admin:user_management.click_twice_same_checkbox')}</span> }
+            </div>
+          </div>
+
+          <div className="col-md-2 my-2">
+            <button
+              type="button"
+              className="btn btn-outline-secondary btn-sm"
+              onClick={resetButtonClickHandler}
+            >
+              <span className="icon-refresh mr-1"></span>
+              Reset
+            </button>
+          </div>
+        </div>
+      </div>
+
+      {pager}
+      <UserTable />
+      {pager}
+
+    </div>
+  );
+
+};
+
+const UserManagementWrapper = withUnstatedContainers(UserManagement, [AdminUsersContainer]);
+
+export default UserManagementWrapper;

+ 0 - 132
packages/app/src/components/Admin/Users/ExternalAccountTable.jsx

@@ -1,132 +0,0 @@
-import React, { Fragment } from 'react';
-
-import dateFnsFormat from 'date-fns/format';
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-class ExternalAccountTable extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-
-    };
-    this.removeExtenalAccount = this.removeExtenalAccount.bind(this);
-  }
-
-  // remove external-account
-  async removeExtenalAccount(externalAccountId) {
-    const { t } = this.props;
-
-    try {
-      const accountId = await this.props.adminExternalAccountsContainer.removeExternalAccountById(externalAccountId);
-      toastSuccess(t('toaster.remove_external_user_success', { accountId }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-
-  render() {
-    const { t, adminExternalAccountsContainer } = this.props;
-    return (
-      <Fragment>
-        <table className="table table-bordered table-user-list">
-          <thead>
-            <tr>
-              <th width="120px">{t('admin:user_management.authentication_provider')}</th>
-              <th><code>accountId</code></th>
-              <th>{t('admin:user_management.related_username')}<code>username</code></th>
-              <th>
-                {t('admin:user_management.password_setting')}
-                <div
-                  className="text-muted"
-                  data-toggle="popover"
-                  data-placement="top"
-                  data-trigger="hover focus"
-                  tabIndex="0"
-                  role="button"
-                  data-animation="false"
-                  data-html="true"
-                  data-content={t('admin:user_management.password_setting_help')}
-                >
-                  <small>
-                    <i className="icon-question" aria-hidden="true"></i>
-                  </small>
-                </div>
-              </th>
-              <th width="100px">{t('Created')}</th>
-              <th width="70px"></th>
-            </tr>
-          </thead>
-          <tbody>
-            {adminExternalAccountsContainer.state.externalAccounts.map((ea) => {
-              return (
-                <tr key={ea._id}>
-                  <td>{ea.providerType}</td>
-                  <td>
-                    <strong>{ea.accountId}</strong>
-                  </td>
-                  <td>
-                    <strong>{ea.user.username}</strong>
-                  </td>
-                  <td>
-                    {ea.user.password
-                      ? (
-                        <span className="badge badge-info">
-                          {t('admin:user_management.set')}
-                        </span>
-                      )
-                      : (
-                        <span className="badge badge-warning">
-                          {t('admin:user_management.unset')}
-                        </span>
-                      )
-                    }
-                  </td>
-                  <td>{dateFnsFormat(new Date(ea.createdAt), 'yyyy-MM-dd')}</td>
-                  <td>
-                    <div className="btn-group admin-user-menu">
-                      <button type="button" className="btn btn-outline-secondary btn-sm dropdown-toggle" data-toggle="dropdown">
-                        <i className="icon-settings"></i> <span className="caret"></span>
-                      </button>
-                      <ul className="dropdown-menu" role="menu">
-                        <li className="dropdown-header">{t('admin:user_management.user_table.edit_menu')}</li>
-                        <button className="dropdown-item" type="button" role="button" onClick={() => { return this.removeExtenalAccount(ea._id) }}>
-                          <i className="icon-fw icon-fire text-danger"></i> {t('Delete')}
-                        </button>
-                      </ul>
-                    </div>
-                  </td>
-                </tr>
-              );
-            })}
-          </tbody>
-        </table>
-      </Fragment>
-    );
-  }
-
-}
-
-ExternalAccountTable.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminExternalAccountsContainer: PropTypes.instanceOf(AdminExternalAccountsContainer).isRequired,
-};
-
-const ExternalAccountTableWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <ExternalAccountTable t={t} {...props} />;
-};
-
-const ExternalAccountTableWrapper = withUnstatedContainers(ExternalAccountTableWrapperFC, [AdminExternalAccountsContainer]);
-
-
-export default ExternalAccountTableWrapper;

+ 8 - 0
packages/app/src/components/Admin/Users/ExternalAccountTable.module.scss

@@ -0,0 +1,8 @@
+.ea-table :global {
+  thead th {
+    vertical-align: top;
+  }
+  td {
+    vertical-align: middle;
+  }
+}

+ 122 - 0
packages/app/src/components/Admin/Users/ExternalAccountTable.tsx

@@ -0,0 +1,122 @@
+import React, { useCallback } from 'react';
+
+import type { IAdminExternalAccount } from '@growi/core';
+import dateFnsFormat from 'date-fns/format';
+import { useTranslation } from 'next-i18next';
+
+import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+import styles from './ExternalAccountTable.module.scss';
+
+type ExternalAccountTableProps = {
+  adminExternalAccountsContainer: AdminExternalAccountsContainer,
+}
+
+const ExternalAccountTable = (props: ExternalAccountTableProps): JSX.Element => {
+
+  const { t } = useTranslation('admin');
+
+  const { adminExternalAccountsContainer } = props;
+
+  const removeExtenalAccount = useCallback(async(externalAccountId) => {
+    try {
+      const accountId = await adminExternalAccountsContainer.removeExternalAccountById(externalAccountId);
+      toastSuccess(t('toaster.remove_external_user_success', { accountId }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminExternalAccountsContainer, t]);
+
+  return (
+    <div className="table-responsive text-nowrap">
+      <table className={`${styles['ea-table']} table table-bordered table-user-list`}>
+        <thead>
+          <tr>
+            <th style={{ width: '100px' }}>
+              <div className="d-flex align-items-center">
+                {t('user_management.authentication_provider')}
+              </div>
+            </th>
+            <th style={{ width: '200px' }}>
+              <div className="d-flex align-items-center">
+                <code>accountId</code>
+              </div>
+            </th>
+            <th style={{ width: '200px' }}>
+              <div className="d-flex align-items-center">
+                {t('user_management.related_username')}<code className="ml-2">username</code>
+              </div>
+            </th>
+            <th style={{ width: '100px' }}>
+              <div className="d-flex align-items-center">
+                {t('user_management.password_setting')}
+                <span
+                  role="button"
+                  className="text-muted mx-2"
+                  data-toggle="popper"
+                  data-placement="top"
+                  data-trigger="hover"
+                  data-html="true"
+                  title={t('user_management.password_setting_help')}
+                >
+                  <small><i className="icon-question" aria-hidden="true"></i></small>
+                </span>
+              </div>
+            </th>
+            <th style={{ width: '100px' }}>
+              <div className="d-flex align-items-center">
+                {t('Created')}
+              </div>
+            </th>
+            <th style={{ width: '70px' }}></th>
+          </tr>
+        </thead>
+        <tbody>
+          { adminExternalAccountsContainer.state.externalAccounts.map((ea: IAdminExternalAccount) => {
+            return (
+              <tr key={ea._id}>
+                <td><span>{ea.providerType}</span></td>
+                <td><strong>{ea.accountId}</strong></td>
+                <td><strong>{ea.user.username}</strong></td>
+                <td>
+                  {ea.user.password
+                    ? (<span className="badge badge-info">{t('user_management.set')}</span>)
+                    : (<span className="badge badge-warning">{t('user_management.unset')}</span>)
+                  }
+                </td>
+                <td>{dateFnsFormat(new Date(ea.createdAt), 'yyyy-MM-dd')}</td>
+                <td>
+                  <div className="btn-group admin-user-menu">
+                    <button type="button" className="btn btn-outline-secondary btn-sm dropdown-toggle" data-toggle="dropdown">
+                      <i className="icon-settings"></i> <span className="caret"></span>
+                    </button>
+                    <ul className="dropdown-menu" role="menu">
+                      <li className="dropdown-header">{t('user_management.user_table.edit_menu')}</li>
+                      <button
+                        className="dropdown-item"
+                        type="button"
+                        role="button"
+                        onClick={() => removeExtenalAccount(ea._id)}
+                      >
+                        <i className="icon-fw icon-fire text-danger"></i> {t('Delete')}
+                      </button>
+                    </ul>
+                  </div>
+                </td>
+              </tr>
+            );
+          }) }
+        </tbody>
+      </table>
+    </div>
+
+  );
+};
+
+const ExternalAccountTableWrapper = withUnstatedContainers(ExternalAccountTable, [AdminExternalAccountsContainer]);
+
+export default ExternalAccountTableWrapper;

+ 0 - 60
packages/app/src/components/Admin/Users/GiveAdminButton.jsx

@@ -1,60 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-class GiveAdminButton extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.onClickGiveAdminBtn = this.onClickGiveAdminBtn.bind(this);
-  }
-
-  async onClickGiveAdminBtn() {
-    const { t } = this.props;
-
-    try {
-      const username = await this.props.adminUsersContainer.giveUserAdmin(this.props.user._id);
-      toastSuccess(t('toaster.give_user_admin', { username }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <button className="dropdown-item" type="button" onClick={() => { this.onClickGiveAdminBtn() }}>
-        <i className="icon-fw icon-user-following"></i> {t('admin:user_management.user_table.give_admin_access')}
-      </button>
-    );
-  }
-
-}
-
-const GiveAdminButtonWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <GiveAdminButton t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const GiveAdminButtonWrapper = withUnstatedContainers(GiveAdminButtonWrapperFC, [AdminUsersContainer]);
-
-GiveAdminButton.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
-
-  user: PropTypes.object.isRequired,
-};
-
-export default GiveAdminButtonWrapper;

+ 44 - 0
packages/app/src/components/Admin/Users/GiveAdminButton.tsx

@@ -0,0 +1,44 @@
+import React, { useCallback } from 'react';
+
+import type { IUserHasId } from '@growi/core';
+import { useTranslation } from 'next-i18next';
+
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+type GiveAdminButtonProps = {
+  adminUsersContainer: AdminUsersContainer,
+  user: IUserHasId,
+}
+
+const GiveAdminButton = (props: GiveAdminButtonProps): JSX.Element => {
+
+  const { t } = useTranslation();
+  const { adminUsersContainer, user } = props;
+
+  const onClickGiveAdminBtnHandler = useCallback(async() => {
+    try {
+      const username = await adminUsersContainer.giveUserAdmin(user._id);
+      toastSuccess(t('toaster.give_user_admin', { username }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminUsersContainer, t, user._id]);
+
+  return (
+    <button className="dropdown-item" type="button" onClick={() => onClickGiveAdminBtnHandler()}>
+      <i className="icon-fw icon-user-following"></i> {t('admin:user_management.user_table.give_admin_access')}
+    </button>
+  );
+
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GiveAdminButtonWrapper = withUnstatedContainers(GiveAdminButton, [AdminUsersContainer]);
+
+export default GiveAdminButtonWrapper;

+ 67 - 0
packages/app/src/components/Admin/Users/RemoveAdminButton.tsx

@@ -0,0 +1,67 @@
+import React, { useCallback } from 'react';
+
+import type { IUserHasId } from '@growi/core';
+import { useTranslation } from 'next-i18next';
+
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { useCurrentUser } from '~/stores/context';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+type RemoveAdminButtonProps = {
+  adminUsersContainer: AdminUsersContainer,
+  user: IUserHasId,
+}
+
+const RemoveAdminButton = (props: RemoveAdminButtonProps): JSX.Element => {
+
+  const { t } = useTranslation();
+  const { data: currentUser } = useCurrentUser();
+  const { adminUsersContainer, user } = props;
+
+  const onClickRemoveAdminBtnHandler = useCallback(async() => {
+    try {
+      const username = await adminUsersContainer.removeUserAdmin(user._id);
+      toastSuccess(t('toaster.remove_user_admin', { username }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminUsersContainer, t, user._id]);
+
+  const renderRemoveAdminBtn = () => {
+    return (
+      <button className="dropdown-item" type="button" onClick={() => onClickRemoveAdminBtnHandler()}>
+        <i className="icon-fw icon-user-unfollow"></i>{t('admin:user_management.user_table.remove_admin_access')}
+      </button>
+    );
+  };
+
+  const renderRemoveAdminAlert = () => {
+    return (
+      <div className="px-4">
+        <i className="icon-fw icon-user-unfollow mb-2"></i>{t('admin:user_management.user_table.remove_admin_access')}
+        <p className="alert alert-danger">{t('admin:user_management.user_table.cannot_remove')}</p>
+      </div>
+    );
+  };
+
+  if (currentUser == null) {
+    return <></>;
+  }
+
+  return (
+    <>
+      {user.username !== currentUser.username ? renderRemoveAdminBtn()
+        : renderRemoveAdminAlert()}
+    </>
+  );
+};
+
+/**
+* Wrapper component for using unstated
+*/
+const RemoveAdminButtonWrapper = withUnstatedContainers(RemoveAdminButton, [AdminUsersContainer]);
+
+export default RemoveAdminButtonWrapper;

+ 1 - 1
packages/app/src/components/Admin/Users/RemoveAdminMenuItem.tsx

@@ -1,10 +1,10 @@
 import React, { useCallback } from 'react';
 import React, { useCallback } from 'react';
 
 
+import type { IUserHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { IUserHasId } from '~/interfaces/user';
 import { useCurrentUser } from '~/stores/context';
 import { useCurrentUser } from '~/stores/context';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';

+ 9 - 13
packages/app/src/components/Admin/Users/SortIcons.jsx → packages/app/src/components/Admin/Users/SortIcons.tsx

@@ -1,31 +1,27 @@
 import React from 'react';
 import React from 'react';
 
 
-import PropTypes from 'prop-types';
+type SortIconsProps = {
+  onClick: (sortOrder: string) => void,
+  isSelected: boolean,
+  isAsc: boolean,
+}
 
 
-const SortIcons = (props) => {
+export const SortIcons = (props: SortIconsProps): JSX.Element => {
 
 
-  const { isSelected, isAsc } = props;
+  const { onClick, isSelected, isAsc } = props;
 
 
   return (
   return (
     <div className="d-flex flex-column text-center">
     <div className="d-flex flex-column text-center">
       <a
       <a
         className={`fa ${isSelected && isAsc ? 'fa-chevron-up' : 'fa-angle-up'}`}
         className={`fa ${isSelected && isAsc ? 'fa-chevron-up' : 'fa-angle-up'}`}
         aria-hidden="true"
         aria-hidden="true"
-        onClick={() => props.onClick('asc')}
+        onClick={() => onClick('asc')}
       />
       />
       <a
       <a
         className={`fa ${isSelected && !isAsc ? 'fa-chevron-down' : 'fa-angle-down'}`}
         className={`fa ${isSelected && !isAsc ? 'fa-chevron-down' : 'fa-angle-down'}`}
         aria-hidden="true"
         aria-hidden="true"
-        onClick={() => props.onClick('desc')}
+        onClick={() => onClick('desc')}
       />
       />
     </div>
     </div>
   );
   );
 };
 };
-
-SortIcons.propTypes = {
-  onClick: PropTypes.func.isRequired,
-  isSelected: PropTypes.bool.isRequired,
-  isAsc: PropTypes.bool.isRequired,
-};
-
-export default SortIcons;

+ 1 - 1
packages/app/src/components/Admin/Users/StatusSuspendMenuItem.tsx

@@ -1,11 +1,11 @@
 import React, { useCallback } from 'react';
 import React, { useCallback } from 'react';
 
 
+import type { IUserHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { withUnstatedContainers } from '~/components/UnstatedUtils';
 import { withUnstatedContainers } from '~/components/UnstatedUtils';
-import { IUserHasId } from '~/interfaces/user';
 import { useCurrentUser } from '~/stores/context';
 import { useCurrentUser } from '~/stores/context';
 
 
 
 

+ 0 - 231
packages/app/src/components/Admin/Users/UserTable.jsx

@@ -1,231 +0,0 @@
-import React, { Fragment } from 'react';
-
-import { UserPicture } from '@growi/ui';
-import dateFnsFormat from 'date-fns/format';
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-import SortIcons from './SortIcons';
-import UserMenu from './UserMenu';
-
-
-class UserTable extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-
-    };
-
-    this.getUserStatusLabel = this.getUserStatusLabel.bind(this);
-  }
-
-  /**
-   * return status label element by `userStatus`
-   * @param {string} userStatus
-   * @return status label element
-   */
-  getUserStatusLabel(userStatus) {
-    let additionalClassName;
-    let text;
-
-    switch (userStatus) {
-      case 1:
-        additionalClassName = 'badge-info';
-        text = 'Approval Pending';
-        break;
-      case 2:
-        additionalClassName = 'badge-success';
-        text = 'Active';
-        break;
-      case 3:
-        additionalClassName = 'badge-warning';
-        text = 'Suspended';
-        break;
-      case 4:
-        additionalClassName = 'badge-danger';
-        text = 'Deleted';
-        break;
-      case 5:
-        additionalClassName = 'badge-pink';
-        text = 'Invited';
-        break;
-    }
-
-    return (
-      <span className={`badge badge-pill ${additionalClassName}`}>
-        {text}
-      </span>
-    );
-  }
-
-  /**
-   * return admin label element by `isAdmin`
-   * @param {string} isAdmin
-   * @return admin label element
-   */
-  getUserAdminLabel(isAdmin) {
-    const { t } = this.props;
-
-    if (isAdmin) {
-      return <span className="badge badge-indigo badge-pill ml-2">{t('admin:user_management.user_table.administrator')}</span>;
-    }
-  }
-
-  sortIconsClickedHandler(sort, sortOrder) {
-    const isAsc = sortOrder === 'asc';
-
-    const { adminUsersContainer } = this.props;
-    adminUsersContainer.sort(sort, isAsc);
-  }
-
-  render() {
-    const { t, adminUsersContainer } = this.props;
-
-    const isCurrentSortOrderAsc = adminUsersContainer.state.sortOrder === 'asc';
-
-    return (
-      <Fragment>
-        <div className="table-responsive text-nowrap h-100">
-          <table className="table table-default table-bordered table-user-list">
-            <thead>
-              <tr>
-                <th width="100px">#</th>
-                <th>
-                  <div className="d-flex align-items-center">
-                    <div className="mr-3">
-                      {t('user_management.status')}
-                    </div>
-                    <SortIcons
-                      isSelected={adminUsersContainer.state.sort === 'status'}
-                      isAsc={isCurrentSortOrderAsc}
-                      onClick={(sortOrder) => {
-                        this.sortIconsClickedHandler('status', sortOrder);
-                      }}
-                    />
-                  </div>
-                </th>
-                <th>
-                  <div className="d-flex align-items-center">
-                    <div className="mr-3">
-                      <code>username</code>
-                    </div>
-                    <SortIcons
-                      isSelected={adminUsersContainer.state.sort === 'username'}
-                      isAsc={isCurrentSortOrderAsc}
-                      onClick={(sortOrder) => {
-                        this.sortIconsClickedHandler('username', sortOrder);
-                      }}
-                    />
-                  </div>
-                </th>
-                <th>
-                  <div className="d-flex align-items-center">
-                    <div className="mr-3">
-                      {t('Name')}
-                    </div>
-                    <SortIcons
-                      isSelected={adminUsersContainer.state.sort === 'name'}
-                      isAsc={isCurrentSortOrderAsc}
-                      onClick={(sortOrder) => {
-                        this.sortIconsClickedHandler('name', sortOrder);
-                      }}
-                    />
-                  </div>
-                </th>
-                <th>
-                  <div className="d-flex align-items-center">
-                    <div className="mr-3">
-                      {t('Email')}
-                    </div>
-                    <SortIcons
-                      isSelected={adminUsersContainer.state.sort === 'email'}
-                      isAsc={isCurrentSortOrderAsc}
-                      onClick={(sortOrder) => {
-                        this.sortIconsClickedHandler('email', sortOrder);
-                      }}
-                    />
-                  </div>
-                </th>
-                <th width="100px">
-                  <div className="d-flex align-items-center">
-                    <div className="mr-3">
-                      {t('Created')}
-                    </div>
-                    <SortIcons
-                      isSelected={adminUsersContainer.state.sort === 'createdAt'}
-                      isAsc={isCurrentSortOrderAsc}
-                      onClick={(sortOrder) => {
-                        this.sortIconsClickedHandler('createdAt', sortOrder);
-                      }}
-                    />
-                  </div>
-                </th>
-                <th width="150px">
-                  <div className="d-flex align-items-center">
-                    <div className="mr-3">
-                      {t('last_login')}
-                    </div>
-                    <SortIcons
-                      isSelected={adminUsersContainer.state.sort === 'lastLoginAt'}
-                      isAsc={isCurrentSortOrderAsc}
-                      onClick={(sortOrder) => {
-                        this.sortIconsClickedHandler('lastLoginAt', sortOrder);
-                      }}
-                    />
-                  </div>
-                </th>
-                <th width="70px"></th>
-              </tr>
-            </thead>
-            <tbody>
-              {adminUsersContainer.state.users.map((user) => {
-                return (
-                  <tr data-testid="user-table-tr" key={user._id}>
-                    <td>
-                      <UserPicture user={user} className="picture rounded-circle" />
-                    </td>
-                    <td>{this.getUserStatusLabel(user.status)} {this.getUserAdminLabel(user.admin)}</td>
-                    <td>
-                      <strong>{user.username}</strong>
-                    </td>
-                    <td>{user.name}</td>
-                    <td>{user.email}</td>
-                    <td>{dateFnsFormat(new Date(user.createdAt), 'yyyy-MM-dd')}</td>
-                    <td>
-                      {user.lastLoginAt && <span>{dateFnsFormat(new Date(user.lastLoginAt), 'yyyy-MM-dd HH:mm')}</span>}
-                    </td>
-                    <td>
-                      <UserMenu user={user} />
-                    </td>
-                  </tr>
-                );
-              })}
-            </tbody>
-          </table>
-        </div>
-      </Fragment>
-    );
-  }
-
-}
-
-
-UserTable.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
-};
-
-const UserTableWrapperFC = (props) => {
-  const { t } = useTranslation('admin');
-  return <UserTable t={t} {...props} />;
-};
-
-const UserTableWrapper = withUnstatedContainers(UserTableWrapperFC, [AdminUsersContainer]);
-
-export default UserTableWrapper;

+ 185 - 0
packages/app/src/components/Admin/Users/UserTable.tsx

@@ -0,0 +1,185 @@
+import React, { useCallback } from 'react';
+
+import type { IUserHasId } from '@growi/core';
+import { UserPicture } from '@growi/ui';
+import dateFnsFormat from 'date-fns/format';
+import { useTranslation } from 'next-i18next';
+
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+import { SortIcons } from './SortIcons';
+import UserMenu from './UserMenu';
+
+type UserTableProps = {
+  adminUsersContainer: AdminUsersContainer,
+}
+
+const UserTable = (props: UserTableProps) => {
+
+  const { t } = useTranslation('admin');
+  const { adminUsersContainer } = props;
+
+  const getUserStatusLabel = (userStatus: number) => {
+    let additionalClassName = 'badge-info';
+    let text = 'Approval Pending';
+
+    switch (userStatus) {
+      case 1:
+        additionalClassName = 'badge-info';
+        text = 'Approval Pending';
+        break;
+      case 2:
+        additionalClassName = 'badge-success';
+        text = 'Active';
+        break;
+      case 3:
+        additionalClassName = 'badge-warning';
+        text = 'Suspended';
+        break;
+      case 4:
+        additionalClassName = 'badge-danger';
+        text = 'Deleted';
+        break;
+      case 5:
+        additionalClassName = 'badge-pink';
+        text = 'Invited';
+        break;
+    }
+
+    return (
+      <span className={`badge badge-pill ${additionalClassName}`}>
+        {text}
+      </span>
+    );
+  };
+
+  const sortIconsClickedHandler = useCallback(async(sort: string, sortOrder: string) => {
+    const isAsc = sortOrder === 'asc';
+    await adminUsersContainer.sort(sort, isAsc);
+  }, [adminUsersContainer]);
+
+  const isCurrentSortOrderAsc = adminUsersContainer.state.sortOrder === 'asc';
+
+  return (
+    <div className="table-responsive text-nowrap h-100">
+      <table className="table table-default table-bordered table-user-list">
+        <thead>
+          <tr>
+            <th style={{ width: '100px' }}>#</th>
+            <th>
+              <div className="d-flex align-items-center">
+                <div className="mr-3">
+                  {t('user_management.status')}
+                </div>
+                <SortIcons
+                  isSelected={adminUsersContainer.state.sort === 'status'}
+                  isAsc={isCurrentSortOrderAsc}
+                  onClick={sortOrder => sortIconsClickedHandler('status', sortOrder)}
+                />
+              </div>
+            </th>
+            <th>
+              <div className="d-flex align-items-center">
+                <div className="mr-3">
+                  <code>username</code>
+                </div>
+                <SortIcons
+                  isSelected={adminUsersContainer.state.sort === 'username'}
+                  isAsc={isCurrentSortOrderAsc}
+                  onClick={sortOrder => sortIconsClickedHandler('username', sortOrder)}
+                />
+              </div>
+            </th>
+            <th>
+              <div className="d-flex align-items-center">
+                <div className="mr-3">
+                  {t('Name')}
+                </div>
+                <SortIcons
+                  isSelected={adminUsersContainer.state.sort === 'name'}
+                  isAsc={isCurrentSortOrderAsc}
+                  onClick={sortOrder => sortIconsClickedHandler('name', sortOrder)}
+                />
+              </div>
+            </th>
+            <th>
+              <div className="d-flex align-items-center">
+                <div className="mr-3">
+                  {t('Email')}
+                </div>
+                <SortIcons
+                  isSelected={adminUsersContainer.state.sort === 'email'}
+                  isAsc={isCurrentSortOrderAsc}
+                  onClick={sortOrder => sortIconsClickedHandler('email', sortOrder)}
+                />
+              </div>
+            </th>
+            <th style={{ width: '100px' }}>
+              <div className="d-flex align-items-center">
+                <div className="mr-3">
+                  {t('Created')}
+                </div>
+                <SortIcons
+                  isSelected={adminUsersContainer.state.sort === 'createdAt'}
+                  isAsc={isCurrentSortOrderAsc}
+                  onClick={sortOrder => sortIconsClickedHandler('createdAt', sortOrder)}
+                />
+              </div>
+            </th>
+            <th style={{ width: '150px' }}>
+              <div className="d-flex align-items-center">
+                <div className="mr-3">
+                  {t('last_login')}
+                </div>
+                <SortIcons
+                  isSelected={adminUsersContainer.state.sort === 'lastLoginAt'}
+                  isAsc={isCurrentSortOrderAsc}
+                  onClick={sortOrder => sortIconsClickedHandler('lastLoginAt', sortOrder)}
+                />
+              </div>
+            </th>
+            <th style={{ width: '70px' }}></th>
+          </tr>
+        </thead>
+        <tbody>
+          { adminUsersContainer.state.users.map((user: IUserHasId) => {
+            return (
+              <tr data-testid="user-table-tr" key={user._id}>
+                <td>
+                  <UserPicture user={user} />
+                </td>
+                <td>
+                  {getUserStatusLabel(user.status)}
+                  {(user.admin) && (
+                    <span className="badge badge-indigo badge-pill ml-2">
+                      {t('admin:user_management.user_table.administrator')}
+                    </span>
+                  )}
+                </td>
+                <td>
+                  <strong>{user.username}</strong>
+                </td>
+                <td>{user.name}</td>
+                <td>{user.email}</td>
+                <td>{dateFnsFormat(new Date(user.createdAt), 'yyyy-MM-dd')}</td>
+                <td>
+                  {user.lastLoginAt && <span>{dateFnsFormat(new Date(user.lastLoginAt), 'yyyy-MM-dd HH:mm')}</span>}
+                </td>
+                <td>
+                  <UserMenu user={user} />
+                </td>
+              </tr>
+            );
+          }) }
+        </tbody>
+      </table>
+    </div>
+  );
+
+};
+
+const UserTableWrapper = withUnstatedContainers(UserTable, [AdminUsersContainer]);
+
+export default UserTableWrapper;

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

@@ -44,7 +44,7 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
       const res = await apiv3Post('/invited', { invitedForm });
       const res = await apiv3Post('/invited', { invitedForm });
       setIsConnectSuccess(true);
       setIsConnectSuccess(true);
       const { redirectTo } = res.data;
       const { redirectTo } = res.data;
-      router.push(redirectTo);
+      router.push(redirectTo ?? '/');
     }
     }
     catch (err) {
     catch (err) {
       setLoginErrors(err);
       setLoginErrors(err);

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

@@ -21,7 +21,7 @@ type Props = {
 }
 }
 
 
 export const RawLayout = ({ children, title, className }: Props): JSX.Element => {
 export const RawLayout = ({ children, title, className }: Props): JSX.Element => {
-  const classNames: string[] = ['layout-root'];
+  const classNames: string[] = ['layout-root', 'growi'];
   if (className != null) {
   if (className != null) {
     classNames.push(className);
     classNames.push(className);
   }
   }

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

@@ -271,7 +271,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     try {
     try {
       const res = await apiv3Post(requestPath, { registerForm });
       const res = await apiv3Post(requestPath, { registerForm });
       const { redirectTo } = res.data;
       const { redirectTo } = res.data;
-      router.push(redirectTo);
+      router.push(redirectTo ?? '/');
     }
     }
     catch (err) {
     catch (err) {
       // Execute if error exists
       // Execute if error exists

+ 24 - 27
packages/app/src/components/Navbar/AuthorInfo.jsx → packages/app/src/components/Navbar/AuthorInfo.tsx

@@ -1,18 +1,26 @@
 import React from 'react';
 import React from 'react';
-import PropTypes from 'prop-types';
-import { format } from 'date-fns';
-import { UserPicture } from '@growi/ui';
-import { pagePathUtils } from '@growi/core';
 
 
-const { userPageRoot } = pagePathUtils;
+import { pagePathUtils } from '@growi/core';
+import type { IUser } from '@growi/core';
+import { UserPicture } from '@growi/ui';
+import { format } from 'date-fns';
+import Link from 'next/link';
 
 
+export type AuthorInfoProps = {
+  date: Date,
+  user: IUser,
+  mode: 'create' | 'update',
+  locate: 'subnav' | 'footer',
+}
 
 
-const formatType = 'yyyy/MM/dd HH:mm';
-const AuthorInfo = (props) => {
+export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
   const {
   const {
-    mode, user, date, locate,
+    date, user, mode = 'create', locate = 'subnav',
   } = props;
   } = props;
 
 
+  const { userPageRoot } = pagePathUtils;
+  const formatType = 'yyyy/MM/dd HH:mm';
+
   const infoLabelForSubNav = mode === 'create'
   const infoLabelForSubNav = mode === 'create'
     ? 'Created by'
     ? 'Created by'
     : 'Updated by';
     : 'Updated by';
@@ -23,16 +31,20 @@ const AuthorInfo = (props) => {
     ? 'Created at'
     ? 'Created at'
     : 'Last revision posted at';
     : 'Last revision posted at';
   const userLabel = user != null
   const userLabel = user != null
-    ? <a href={userPageRoot(user)}>{user.name}</a>
+    ? (
+      <Link href={userPageRoot(user)} prefetch={false}>
+        <a>{user.name}</a>
+      </Link>
+    )
     : <i>Unknown</i>;
     : <i>Unknown</i>;
 
 
   if (locate === 'footer') {
   if (locate === 'footer') {
     try {
     try {
-      return <p>{infoLabelForFooter} {format(new Date(date), formatType)} by <UserPicture user={user} size="sm" /> {userLabel}</p>;
+      return <p>{infoLabelForFooter} {format(new Date(date), formatType)} by <UserPicture user={user} size="sm"/> {userLabel}</p>;
     }
     }
     catch (err) {
     catch (err) {
       if (err instanceof RangeError) {
       if (err instanceof RangeError) {
-        return <p>{nullinfoLabelForFooter} <UserPicture user={user} size="sm" /> {userLabel}</p>;
+        return <p>{nullinfoLabelForFooter} <UserPicture user={user} size="sm"/> {userLabel}</p>;
       }
       }
       return <></>;
       return <></>;
     }
     }
@@ -50,7 +62,7 @@ const AuthorInfo = (props) => {
   return (
   return (
     <div className="d-flex align-items-center">
     <div className="d-flex align-items-center">
       <div className="mr-2">
       <div className="mr-2">
-        <UserPicture user={user} size="sm" />
+        <UserPicture user={user} size="sm"/>
       </div>
       </div>
       <div>
       <div>
         <div>{infoLabelForSubNav} {userLabel}</div>
         <div>{infoLabelForSubNav} {userLabel}</div>
@@ -61,18 +73,3 @@ const AuthorInfo = (props) => {
     </div>
     </div>
   );
   );
 };
 };
-
-AuthorInfo.propTypes = {
-  date: PropTypes.instanceOf(Date),
-  user: PropTypes.object,
-  mode: PropTypes.oneOf(['create', 'update']),
-  locate: PropTypes.oneOf(['subnav', 'footer']),
-};
-
-AuthorInfo.defaultProps = {
-  mode: 'create',
-  locate: 'subnav',
-};
-
-
-export default AuthorInfo;

+ 33 - 28
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -3,6 +3,7 @@ import React, { useState, useEffect, useCallback } from 'react';
 import { isPopulated, IUser } from '@growi/core';
 import { isPopulated, IUser } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
+import { useRouter } from 'next/router';
 import { DropdownItem } from 'reactstrap';
 import { DropdownItem } from 'reactstrap';
 
 
 import { exportAsMarkdown } from '~/client/services/page-operation';
 import { exportAsMarkdown } from '~/client/services/page-operation';
@@ -36,8 +37,9 @@ import PresentationIcon from '../Icons/PresentationIcon';
 import ShareLinkIcon from '../Icons/ShareLinkIcon';
 import ShareLinkIcon from '../Icons/ShareLinkIcon';
 import { Skelton } from '../Skelton';
 import { Skelton } from '../Skelton';
 
 
+import type { AuthorInfoProps } from './AuthorInfo';
 import { GrowiSubNavigation } from './GrowiSubNavigation';
 import { GrowiSubNavigation } from './GrowiSubNavigation';
-import { SubNavButtonsProps } from './SubNavButtons';
+import type { SubNavButtonsProps } from './SubNavButtons';
 
 
 import AuthorInfoStyles from './AuthorInfo.module.scss';
 import AuthorInfoStyles from './AuthorInfo.module.scss';
 import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
 import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
@@ -56,7 +58,7 @@ const SubNavButtons = dynamic<SubNavButtonsProps>(
   () => import('./SubNavButtons').then(mod => mod.SubNavButtons),
   () => import('./SubNavButtons').then(mod => mod.SubNavButtons),
   { ssr: false, loading: () => <></> },
   { ssr: false, loading: () => <></> },
 );
 );
-const AuthorInfo = dynamic(() => import('./AuthorInfo'), {
+const AuthorInfo = dynamic<AuthorInfoProps>(() => import('./AuthorInfo').then(mod => mod.AuthorInfo), {
   ssr: false,
   ssr: false,
   loading: AuthorInfoSkelton,
   loading: AuthorInfoSkelton,
 });
 });
@@ -184,6 +186,8 @@ type GrowiContextualSubNavigationProps = {
 
 
 const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps): JSX.Element => {
 const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps): JSX.Element => {
 
 
+  const router = useRouter();
+
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
 
 
   const revision = currentPage?.revision;
   const revision = currentPage?.revision;
@@ -270,43 +274,44 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
     return;
     return;
   }, [mutatePageTagsForEditors]);
   }, [mutatePageTagsForEditors]);
 
 
+  const reload = useCallback(() => {
+    if (currentPathname != null) {
+      router.push(currentPathname);
+    }
+  }, [currentPathname, router]);
+
   const duplicateItemClickedHandler = useCallback(async(page: IPageForPageDuplicateModal) => {
   const duplicateItemClickedHandler = useCallback(async(page: IPageForPageDuplicateModal) => {
     const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
     const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
-      window.location.href = toPath;
+      router.push(toPath);
     };
     };
     openDuplicateModal(page, { onDuplicated: duplicatedHandler });
     openDuplicateModal(page, { onDuplicated: duplicatedHandler });
-  }, [openDuplicateModal]);
+  }, [openDuplicateModal, router]);
 
 
   const renameItemClickedHandler = useCallback(async(page: IPageToRenameWithMeta<IPageInfoForEntity>) => {
   const renameItemClickedHandler = useCallback(async(page: IPageToRenameWithMeta<IPageInfoForEntity>) => {
     const renamedHandler: OnRenamedFunction = () => {
     const renamedHandler: OnRenamedFunction = () => {
-      if (page.data._id !== null) {
-        window.location.href = `/${page.data._id}`;
-        return;
-      }
-      window.location.reload();
+      reload();
     };
     };
     openRenameModal(page, { onRenamed: renamedHandler });
     openRenameModal(page, { onRenamed: renamedHandler });
-  }, [openRenameModal]);
+  }, [openRenameModal, reload]);
 
 
-  const onDeletedHandler: OnDeletedFunction = useCallback((pathOrPathsToDelete, isRecursively, isCompletely) => {
-    if (typeof pathOrPathsToDelete !== 'string') {
-      return;
-    }
-
-    const path = pathOrPathsToDelete;
+  const deleteItemClickedHandler = useCallback((pageWithMeta: IPageWithMeta) => {
+    const deletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
+      if (typeof pathOrPathsToDelete !== 'string') {
+        return;
+      }
 
 
-    if (isCompletely) {
-      // redirect to NotFound Page
-      window.location.href = path;
-    }
-    else {
-      window.location.reload();
-    }
-  }, []);
+      const path = pathOrPathsToDelete;
 
 
-  const deleteItemClickedHandler = useCallback((pageWithMeta: IPageWithMeta) => {
-    openDeleteModal([pageWithMeta], { onDeleted: onDeletedHandler });
-  }, [onDeletedHandler, openDeleteModal]);
+      if (isCompletely) {
+        // redirect to NotFound Page
+        router.push(path);
+      }
+      else {
+        reload();
+      }
+    };
+    openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
+  }, [openDeleteModal, reload, router]);
 
 
   const templateMenuItemClickHandler = useCallback(() => {
   const templateMenuItemClickHandler = useCallback(() => {
     setIsPageTempleteModalShown(true);
     setIsPageTempleteModalShown(true);
@@ -373,7 +378,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
             <ul className={`${AuthorInfoStyles['grw-author-info']} text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3`}>
             <ul className={`${AuthorInfoStyles['grw-author-info']} text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3`}>
               <li className="pb-1">
               <li className="pb-1">
                 { currentPage != null
                 { currentPage != null
-                  ? <AuthorInfo user={currentPage.creator as IUser} date={currentPage.createdAt} locate="subnav" />
+                  ? <AuthorInfo user={currentPage.creator as IUser} date={currentPage.createdAt} mode="create" locate="subnav" />
                   : <AuthorInfoSkelton />
                   : <AuthorInfoSkelton />
                 }
                 }
               </li>
               </li>

+ 8 - 9
packages/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -2,11 +2,10 @@ import React from 'react';
 
 
 import { UserPicture } from '@growi/ui';
 import { UserPicture } from '@growi/ui';
 import { format } from 'date-fns';
 import { format } from 'date-fns';
+import { useRouter } from 'next/router';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
-import {
-  useIsTrashPage, useShareLinkId,
-} from '~/stores/context';
+import { useIsTrashPage } from '~/stores/context';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import { useSWRxPageInfo, useSWRxCurrentPage } from '~/stores/page';
 import { useSWRxPageInfo, useSWRxCurrentPage } from '~/stores/page';
 import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
 import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
@@ -21,14 +20,14 @@ const onDeletedHandler = (pathOrPathsToDelete) => {
 
 
 export const TrashPageAlert = (): JSX.Element => {
 export const TrashPageAlert = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const router = useRouter();
 
 
   const { data: isAbleToShowTrashPageManagementButtons } = useIsAbleToShowTrashPageManagementButtons();
   const { data: isAbleToShowTrashPageManagementButtons } = useIsAbleToShowTrashPageManagementButtons();
-  const { data: shareLinkId } = useShareLinkId();
   const { data: pageData } = useSWRxCurrentPage();
   const { data: pageData } = useSWRxCurrentPage();
   const { data: isTrashPage } = useIsTrashPage();
   const { data: isTrashPage } = useIsTrashPage();
   const pageId = pageData?._id;
   const pageId = pageData?._id;
   const pagePath = pageData?.path;
   const pagePath = pageData?.path;
-  const { data: pageInfo } = useSWRxPageInfo(pageId ?? null, shareLinkId);
+  const { data: pageInfo } = useSWRxPageInfo(pageId ?? null);
 
 
 
 
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openDeleteModal } = usePageDeleteModal();
@@ -39,7 +38,7 @@ export const TrashPageAlert = (): JSX.Element => {
   }
   }
 
 
 
 
-  const lastUpdateUserName = pageData?.lastUpdateUser?.name;
+  const deleteUser = pageData?.deleteUser;
   const deletedAt = pageData?.deletedAt ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm') : '';
   const deletedAt = pageData?.deletedAt ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm') : '';
   const revisionId = pageData?.revision?._id;
   const revisionId = pageData?.revision?._id;
 
 
@@ -49,7 +48,7 @@ export const TrashPageAlert = (): JSX.Element => {
       return;
       return;
     }
     }
     const putBackedHandler = () => {
     const putBackedHandler = () => {
-      window.location.reload();
+      router.push(`/${pageId}`);
     };
     };
     openPutBackPageModal({ pageId, path: pagePath }, { onPutBacked: putBackedHandler });
     openPutBackPageModal({ pageId, path: pagePath }, { onPutBacked: putBackedHandler });
   }
   }
@@ -98,9 +97,9 @@ export const TrashPageAlert = (): JSX.Element => {
         <div className="flex-grow-1">
         <div className="flex-grow-1">
           This page is in the trash <i className="icon-trash" aria-hidden="true"></i>.
           This page is in the trash <i className="icon-trash" aria-hidden="true"></i>.
           <br />
           <br />
-          <UserPicture user={{ username: lastUpdateUserName }} />
+          <UserPicture user={deleteUser} />
           <span className="ml-2">
           <span className="ml-2">
-            Deleted by { lastUpdateUserName } at {deletedAt || pageData?.updatedAt}
+            Deleted by { deleteUser?.name } at {deletedAt || pageData?.updatedAt}
           </span>
           </span>
         </div>
         </div>
         <div className="pt-1 d-flex align-items-end align-items-lg-center">
         <div className="pt-1 d-flex align-items-end align-items-lg-center">

+ 1 - 1
packages/app/src/components/PageAttachment/DeleteAttachmentModal.tsx

@@ -8,7 +8,7 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import Username from '../User/Username';
+import { Username } from '../User/Username';
 
 
 import styles from './DeleteAttachmentModal.module.scss';
 import styles from './DeleteAttachmentModal.module.scss';
 
 

+ 4 - 4
packages/app/src/components/PageComment.tsx

@@ -130,7 +130,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
   const revisionId = getIdForRef(revision);
   const revisionId = getIdForRef(revision);
   const revisionCreatedAt = (isPopulated(revision)) ? revision.createdAt : undefined;
   const revisionCreatedAt = (isPopulated(revision)) ? revision.createdAt : undefined;
 
 
-  const generateCommentElement = (comment: ICommentHasId) => (
+  const commentElement = (comment: ICommentHasId) => (
     <Comment
     <Comment
       rendererOptions={rendererOptions}
       rendererOptions={rendererOptions}
       comment={comment}
       comment={comment}
@@ -143,7 +143,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
     />
     />
   );
   );
 
 
-  const generateReplyCommentsElement = (replyComments: ICommentHasIdList) => (
+  const replyCommentsElement = (replyComments: ICommentHasIdList) => (
     <ReplyComments
     <ReplyComments
       rendererOptions={rendererOptions}
       rendererOptions={rendererOptions}
       isReadOnly={isReadOnly}
       isReadOnly={isReadOnly}
@@ -172,8 +172,8 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
 
 
               return (
               return (
                 <div key={comment._id} className={commentThreadClasses}>
                 <div key={comment._id} className={commentThreadClasses}>
-                  {generateCommentElement(comment)}
-                  {hasReply && generateReplyCommentsElement(allReplies[comment._id])}
+                  {commentElement(comment)}
+                  {hasReply && replyCommentsElement(allReplies[comment._id])}
                   {(!isReadOnly && !showEditorIds.has(comment._id)) && (
                   {(!isReadOnly && !showEditorIds.has(comment._id)) && (
                     <div className="text-right">
                     <div className="text-right">
                       <Button
                       <Button

+ 1 - 1
packages/app/src/components/PageComment/Comment.tsx

@@ -13,7 +13,7 @@ import { ICommentHasId } from '../../interfaces/comment';
 import FormattedDistanceDate from '../FormattedDistanceDate';
 import FormattedDistanceDate from '../FormattedDistanceDate';
 import HistoryIcon from '../Icons/HistoryIcon';
 import HistoryIcon from '../Icons/HistoryIcon';
 import RevisionRenderer from '../Page/RevisionRenderer';
 import RevisionRenderer from '../Page/RevisionRenderer';
-import Username from '../User/Username';
+import { Username } from '../User/Username';
 
 
 import { CommentControl } from './CommentControl';
 import { CommentControl } from './CommentControl';
 import { CommentEditorProps } from './CommentEditor';
 import { CommentEditorProps } from './CommentEditor';

+ 1 - 1
packages/app/src/components/PageComment/DeleteCommentModal.tsx

@@ -7,7 +7,7 @@ import {
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 import { ICommentHasId } from '../../interfaces/comment';
 import { ICommentHasId } from '../../interfaces/comment';
-import Username from '../User/Username';
+import { Username } from '../User/Username';
 
 
 import styles from './DeleteCommentModal.module.scss';
 import styles from './DeleteCommentModal.module.scss';
 
 

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

@@ -1,15 +1,16 @@
 import React from 'react';
 import React from 'react';
 
 
-import { IPage, IUser } from '@growi/core';
+import type { IPage, IUser } from '@growi/core';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 
 
 import { useSWRxCurrentPage } from '~/stores/page';
 import { useSWRxCurrentPage } from '~/stores/page';
 
 
+import type { AuthorInfoProps } from './Navbar/AuthorInfo';
 import { Skelton } from './Skelton';
 import { Skelton } from './Skelton';
 
 
 import styles from './PageContentFooter.module.scss';
 import styles from './PageContentFooter.module.scss';
 
 
-const AuthorInfo = dynamic(() => import('./Navbar/AuthorInfo'), {
+const AuthorInfo = dynamic<AuthorInfoProps>(() => import('./Navbar/AuthorInfo').then(mod => mod.AuthorInfo), {
   ssr: false,
   ssr: false,
   loading: () => <Skelton additionalClass={`${styles['page-content-footer-skelton']} mb-3`} />,
   loading: () => <Skelton additionalClass={`${styles['page-content-footer-skelton']} mb-3`} />,
 });
 });

+ 15 - 0
packages/app/src/components/PageEditor.tsx

@@ -84,6 +84,20 @@ const PageEditor = React.memo((): JSX.Element => {
   const editorRef = useRef<IEditorMethods>(null);
   const editorRef = useRef<IEditorMethods>(null);
   const previewRef = useRef<HTMLDivElement>(null);
   const previewRef = useRef<HTMLDivElement>(null);
 
 
+
+  // const optionsToSave = useMemo(() => {
+  //   if (grantData == null) {
+  //     return;
+  //   }
+  //   const slackChannels = slackChannelsData ? slackChannelsData.toString() : '';
+  //   const optionsToSave = getOptionsToSave(
+  //     isSlackEnabled ?? false, slackChannels,
+  //     grantData.grant, grantData.grantedGroup?.id, grantData.grantedGroup?.name,
+  //     pageTags || [],
+  //   );
+  //   return optionsToSave;
+  // }, [grantData, isSlackEnabled, pageTags, slackChannelsData]);
+
   const setMarkdownWithDebounce = useMemo(() => debounce(100, throttle(150, (value: string, isClean: boolean) => {
   const setMarkdownWithDebounce = useMemo(() => debounce(100, throttle(150, (value: string, isClean: boolean) => {
     markdownToSave.current = value;
     markdownToSave.current = value;
     setMarkdownToPreview(value);
     setMarkdownToPreview(value);
@@ -427,6 +441,7 @@ const PageEditor = React.memo((): JSX.Element => {
         onClose={() => pageContainer.setState({ isConflictDiffModalOpen: false })}
         onClose={() => pageContainer.setState({ isConflictDiffModalOpen: false })}
         pageContainer={pageContainer}
         pageContainer={pageContainer}
         markdownOnEdit={markdown}
         markdownOnEdit={markdown}
+        optionsToSave={optionsToSave}
       /> */}
       /> */}
     </div>
     </div>
   );
   );

+ 5 - 3
packages/app/src/components/PageEditor/ConflictDiffModal.tsx

@@ -2,6 +2,7 @@ import React, {
   useState, useEffect, useRef, useMemo, useCallback,
   useState, useEffect, useRef, useMemo, useCallback,
 } from 'react';
 } from 'react';
 
 
+import type { IUser } from '@growi/core';
 import { UserPicture } from '@growi/ui';
 import { UserPicture } from '@growi/ui';
 import CodeMirror from 'codemirror/lib/codemirror';
 import CodeMirror from 'codemirror/lib/codemirror';
 import { format } from 'date-fns';
 import { format } from 'date-fns';
@@ -10,7 +11,7 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import { IUser } from '~/interfaces/user';
+import type { OptionsToSave } from '~/interfaces/editor-settings';
 import { useCurrentUser } from '~/stores/context';
 import { useCurrentUser } from '~/stores/context';
 import { useEditorMode } from '~/stores/ui';
 import { useEditorMode } from '~/stores/ui';
 
 
@@ -30,6 +31,7 @@ type ConflictDiffModalProps = {
   onClose?: (() => void);
   onClose?: (() => void);
   // pageContainer: PageContainer;
   // pageContainer: PageContainer;
   markdownOnEdit: string;
   markdownOnEdit: string;
+  optionsToSave: OptionsToSave | undefined;
 };
 };
 
 
 type IRevisionOnConflictWithStringDate = Omit<IRevisionOnConflict, 'createdAt'> & {
 type IRevisionOnConflictWithStringDate = Omit<IRevisionOnConflict, 'createdAt'> & {
@@ -109,7 +111,7 @@ const ConflictDiffModalCore = (props: ConflictDiffModalProps & { currentUser: IU
     const codeMirrorVal = uncontrolledRef.current?.editor.doc.getValue();
     const codeMirrorVal = uncontrolledRef.current?.editor.doc.getValue();
 
 
     try {
     try {
-      // await pageContainer.resolveConflict(codeMirrorVal, editorMode);
+      // await pageContainer.resolveConflict(codeMirrorVal, editorMode, props.optionsToSave);
       // close();
       // close();
       // pageContainer.showSuccessToastr();
       // pageContainer.showSuccessToastr();
     }
     }
@@ -117,7 +119,7 @@ const ConflictDiffModalCore = (props: ConflictDiffModalProps & { currentUser: IU
       // pageContainer.showErrorToastr(error);
       // pageContainer.showErrorToastr(error);
     }
     }
 
 
-  }, [editorMode, close]);
+  }, []);
 
 
   const resizeAndCloseButtons = useMemo(() => (
   const resizeAndCloseButtons = useMemo(() => (
     <div className="d-flex flex-nowrap">
     <div className="d-flex flex-nowrap">

+ 7 - 4
packages/app/src/components/PageEditorByHackmd.tsx

@@ -13,8 +13,11 @@ import { apiPost } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import { getOptionsToSave } from '~/client/util/editor';
 import { IResHackmdIntegrated, IResHackmdDiscard } from '~/interfaces/hackmd';
 import { IResHackmdIntegrated, IResHackmdDiscard } from '~/interfaces/hackmd';
 import {
 import {
-  useCurrentPagePath, useCurrentPageId, useHackmdUri, usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced,
+  useCurrentPagePath, useCurrentPageId, useHackmdUri,
 } from '~/stores/context';
 } from '~/stores/context';
+import {
+  usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced, useRemoteRevisionId,
+} from '~/stores/hackmd';
 import {
 import {
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
 } from '~/stores/editor';
 } from '~/stores/editor';
@@ -68,7 +71,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: revisionIdHackmdSynced, mutate: mutateRevisionIdHackmdSynced } = useRevisionIdHackmdSynced();
   const { data: revisionIdHackmdSynced, mutate: mutateRevisionIdHackmdSynced } = useRevisionIdHackmdSynced();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const [isHackmdDraftUpdatingInRealtime, setIsHackmdDraftUpdatingInRealtime] = useState(false);
   const [isHackmdDraftUpdatingInRealtime, setIsHackmdDraftUpdatingInRealtime] = useState(false);
-  const [remoteRevisionId, setRemoteRevisionId] = useState(revision?._id); // initialize
+  const { data: remoteRevisionId, mutate: mutateRemoteRevisionId } = useRemoteRevisionId(revision?._id);
 
 
   const hackmdEditorRef = useRef<HackEditorRef>(null);
   const hackmdEditorRef = useRef<HackEditorRef>(null);
 
 
@@ -204,7 +207,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       setIsHackmdDraftUpdatingInRealtime(false);
       setIsHackmdDraftUpdatingInRealtime(false);
       mutateHasDraftOnHackmd(false);
       mutateHasDraftOnHackmd(false);
       mutatePageIdOnHackmd(res.pageIdOnHackmd);
       mutatePageIdOnHackmd(res.pageIdOnHackmd);
-      setRemoteRevisionId(res.revisionIdHackmdSynced);
+      mutateRemoteRevisionId(res.revisionIdHackmdSynced);
       mutateRevisionIdHackmdSynced(res.revisionIdHackmdSynced);
       mutateRevisionIdHackmdSynced(res.revisionIdHackmdSynced);
 
 
 
 
@@ -235,7 +238,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       mutatePageData(res);
       mutatePageData(res);
 
 
       // set updated data
       // set updated data
-      setRemoteRevisionId(res.revision._id);
+      mutateRemoteRevisionId(res.revision._id);
       mutateRevisionIdHackmdSynced(res.page.revisionHackmdSynced);
       mutateRevisionIdHackmdSynced(res.page.revisionHackmdSynced);
       mutateHasDraftOnHackmd(res.page.hasDraftOnHackmd);
       mutateHasDraftOnHackmd(res.page.hasDraftOnHackmd);
       mutateTagsInfo();
       mutateTagsInfo();

+ 1 - 1
packages/app/src/components/PageHistory/Revision.tsx

@@ -5,7 +5,7 @@ import { UserPicture } from '@growi/ui';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import UserDate from '../User/UserDate';
 import UserDate from '../User/UserDate';
-import Username from '../User/Username';
+import { Username } from '../User/Username';
 
 
 import styles from './Revision.module.scss';
 import styles from './Revision.module.scss';
 
 

+ 5 - 1
packages/app/src/components/PageManagement/ApiErrorMessage.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import React from 'react';
 
 
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 
 
 const ApiErrorMessage = (props) => {
 const ApiErrorMessage = (props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -43,6 +43,10 @@ const ApiErrorMessage = (props) => {
         return (
         return (
           <strong><i className="icon-fw icon-ban"></i> Invalid path</strong>
           <strong><i className="icon-fw icon-ban"></i> Invalid path</strong>
         );
         );
+      case 'single_deletion_empty_pages':
+        return (
+          <strong><i className="icon-fw icon-ban"></i>{ t('page_api_error.single_deletion_empty_pages') }</strong>
+        );
       default:
       default:
         return (
         return (
           <strong><i className="icon-fw icon-ban"></i> Unknown error occured</strong>
           <strong><i className="icon-fw icon-ban"></i> Unknown error occured</strong>

+ 1 - 1
packages/app/src/components/PageStatusAlert.jsx

@@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
 
 
 // import AppContainer from '~/client/services/AppContainer';
 // import AppContainer from '~/client/services/AppContainer';
 // import PageContainer from '~/client/services/PageContainer';
 // import PageContainer from '~/client/services/PageContainer';
-import Username from '~/components/User/Username';
+// import Username from '~/components/User/Username';
 
 
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 

+ 9 - 3
packages/app/src/components/PutbackPageModal.jsx

@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useState, useCallback } from 'react';
 
 
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
@@ -63,6 +63,7 @@ const PutBackPageModal = () => {
       </>
       </>
     );
     );
   };
   };
+
   const BodyContent = () => {
   const BodyContent = () => {
     if (!isOpened) {
     if (!isOpened) {
       return <></>;
       return <></>;
@@ -106,9 +107,14 @@ const PutBackPageModal = () => {
     );
     );
   };
   };
 
 
+  const closeModalHandler = useCallback(() => {
+    closePutBackPageModal();
+    setErrs(null);
+  }, [closePutBackPageModal]);
+
   return (
   return (
-    <Modal isOpen={isOpened} toggle={closePutBackPageModal} className="grw-create-page">
-      <ModalHeader tag="h4" toggle={closePutBackPageModal} className="bg-info text-light">
+    <Modal isOpen={isOpened} toggle={closeModalHandler} className="grw-create-page">
+      <ModalHeader tag="h4" toggle={closeModalHandler} className="bg-info text-light">
         <HeaderContent/>
         <HeaderContent/>
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>

+ 5 - 5
packages/app/src/components/ShareLink/ShareLinkList.tsx

@@ -69,7 +69,7 @@ type Props = {
 
 
 const ShareLinkList = (props: Props): JSX.Element => {
 const ShareLinkList = (props: Props): JSX.Element => {
 
 
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
 
   function renderShareLinks() {
   function renderShareLinks() {
     return (
     return (
@@ -96,10 +96,10 @@ const ShareLinkList = (props: Props): JSX.Element => {
       <table className="table table-bordered">
       <table className="table table-bordered">
         <thead>
         <thead>
           <tr>
           <tr>
-            <th>{t('share_links.Share Link')}</th>
-            {props.isAdmin && <th>{t('share_links.Page Path')}</th>}
-            <th>{t('share_links.expire')}</th>
-            <th>{t('share_links.description')}</th>
+            <th>{t('security_settings.Share Link')}</th>
+            {props.isAdmin && <th>{t('security_settings.Page Path')}</th>}
+            <th>{t('security_settings.expire')}</th>
+            <th>{t('security_settings.description')}</th>
             <th></th>
             <th></th>
           </tr>
           </tr>
         </thead>
         </thead>

+ 7 - 2
packages/app/src/components/TableOfContents.tsx

@@ -1,8 +1,9 @@
 import React, { useCallback } from 'react';
 import React, { useCallback } from 'react';
 
 
+import { pagePathUtils } from '@growi/core';
 import ReactMarkdown from 'react-markdown';
 import ReactMarkdown from 'react-markdown';
 
 
-import { useIsUserPage } from '~/stores/context';
+import { useCurrentPagePath } from '~/stores/context';
 import { useTocOptions } from '~/stores/renderer';
 import { useTocOptions } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -10,12 +11,16 @@ import { StickyStretchableScroller } from './StickyStretchableScroller';
 
 
 import styles from './TableOfContents.module.scss';
 import styles from './TableOfContents.module.scss';
 
 
+const { isUserPage: _isUserPage } = pagePathUtils;
+
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:TableOfContents');
 const logger = loggerFactory('growi:TableOfContents');
 
 
 const TableOfContents = (): JSX.Element => {
 const TableOfContents = (): JSX.Element => {
 
 
-  const { data: isUserPage } = useIsUserPage();
+  const { data: currentPagePath } = useCurrentPagePath();
+
+  const isUserPage = currentPagePath != null && _isUserPage(currentPagePath);
 
 
   // const [tocHtml, setTocHtml] = useState('');
   // const [tocHtml, setTocHtml] = useState('');
 
 

+ 1 - 1
packages/app/src/components/User/UserInfo.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 import React from 'react';
 
 
-import { IUserHasId } from '@growi/core';
+import type { IUserHasId } from '@growi/core';
 import { UserPicture } from '@growi/ui';
 import { UserPicture } from '@growi/ui';
 
 
 import styles from './UserInfo.module.scss';
 import styles from './UserInfo.module.scss';

+ 0 - 30
packages/app/src/components/User/Username.jsx

@@ -1,30 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-export default class Username extends React.Component {
-
-  renderForNull() {
-    return <span>anyone</span>;
-  }
-
-  render() {
-    const { user } = this.props;
-
-    if (user == null) {
-      return this.renderForNull();
-    }
-
-    const name = user.name || '(no name)';
-    const username = user.username;
-    const href = `/user/${user.username}`;
-
-    return (
-      <a href={href}>{name} (@{username})</a>
-    );
-  }
-
-}
-
-Username.propTypes = {
-  user: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), // Possibility of receiving a string of 'null'
-};

+ 26 - 0
packages/app/src/components/User/Username.tsx

@@ -0,0 +1,26 @@
+import React from 'react';
+
+import type { IUser } from '@growi/core';
+import Link from 'next/link';
+
+type UsernameProps = {
+ user?: IUser,
+}
+
+export const Username = (props: UsernameProps): JSX.Element => {
+  const { user } = props;
+
+  if (user == null) {
+    return <span>anyone</span>;
+  }
+
+  const name = user.name || '(no name)';
+  const username = user.username;
+  const href = `/user/${user.username}`;
+
+  return (
+    <Link href={href} prefetch={false}>
+      <a>{name} (@{username})</a>
+    </Link>
+  );
+};

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

@@ -58,11 +58,11 @@ import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 import {
 import {
   useCurrentUser, useCurrentPagePath,
   useCurrentUser, useCurrentPagePath,
   useIsLatestRevision,
   useIsLatestRevision,
-  useIsForbidden, useIsNotFound, useIsTrashPage, useIsSharedUser,
+  useIsForbidden, useIsNotFound, useIsSharedUser,
   useIsEnabledStaleNotification, useIsIdenticalPath,
   useIsEnabledStaleNotification, useIsIdenticalPath,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useDisableLinkSharing,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useDisableLinkSharing,
   useDrawioUri, useHackmdUri, useDefaultIndentSize, useIsIndentSizeForced,
   useDrawioUri, useHackmdUri, useDefaultIndentSize, useIsIndentSizeForced,
-  useIsAclEnabled, useIsUserPage, useIsSearchPage,
+  useIsAclEnabled, useIsSearchPage,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
   useIsSlackConfigured, useRendererConfig, useEditingMarkdown,
   useIsSlackConfigured, useRendererConfig, useEditingMarkdown,
   useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, useLayoutSetting, useCustomizedLogoSrc,
   useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, useLayoutSetting, useCustomizedLogoSrc,
@@ -239,11 +239,9 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   const pagePath = pageWithMeta?.data.path ?? (!_isPermalink(props.currentPathname) ? props.currentPathname : undefined);
   const pagePath = pageWithMeta?.data.path ?? (!_isPermalink(props.currentPathname) ? props.currentPathname : undefined);
 
 
   useCurrentPageId(pageId ?? null);
   useCurrentPageId(pageId ?? null);
-  useIsUserPage(pagePath != null && isUserPage(pagePath));
   // useIsNotCreatable(props.isForbidden || !isCreatablePage(pagePath)); // TODO: need to include props.isIdentical
   // useIsNotCreatable(props.isForbidden || !isCreatablePage(pagePath)); // TODO: need to include props.isIdentical
   useCurrentPagePath(pagePath);
   useCurrentPagePath(pagePath);
   useCurrentPathname(props.currentPathname);
   useCurrentPathname(props.currentPathname);
-  useIsTrashPage(pagePath != null && _isTrashPage(pagePath));
 
 
   useSWRxCurrentPage(undefined, pageWithMeta?.data ?? null); // store initial data
   useSWRxCurrentPage(undefined, pageWithMeta?.data ?? null); // store initial data
   useEditingMarkdown(pageWithMeta?.data.revision?.body ?? '');
   useEditingMarkdown(pageWithMeta?.data.revision?.body ?? '');

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

@@ -29,6 +29,7 @@ type Props = CommonProps & {
   isLocalStrategySetup: boolean,
   isLocalStrategySetup: boolean,
   isLdapStrategySetup: boolean,
   isLdapStrategySetup: boolean,
   isLdapSetupFailed: boolean,
   isLdapSetupFailed: boolean,
+  isEmailAuthenticationEnabled: boolean,
 };
 };
 
 
 const LoginPage: NextPage<Props> = (props: Props) => {
 const LoginPage: NextPage<Props> = (props: Props) => {
@@ -49,11 +50,11 @@ const LoginPage: NextPage<Props> = (props: Props) => {
         isLocalStrategySetup={props.isLocalStrategySetup}
         isLocalStrategySetup={props.isLocalStrategySetup}
         isLdapStrategySetup={props.isLdapStrategySetup}
         isLdapStrategySetup={props.isLdapStrategySetup}
         isLdapSetupFailed={props.isLdapSetupFailed}
         isLdapSetupFailed={props.isLdapSetupFailed}
-        isEmailAuthenticationEnabled={false}
+        isEmailAuthenticationEnabled={props.isEmailAuthenticationEnabled}
         isRegistrationEnabled={true}
         isRegistrationEnabled={true}
         registrationWhiteList={props.registrationWhiteList}
         registrationWhiteList={props.registrationWhiteList}
         isPasswordResetEnabled={true}
         isPasswordResetEnabled={true}
-        isMailerSetup={false}
+        isMailerSetup={true}
       />
       />
     </NoLoginLayout>
     </NoLoginLayout>
   );
   );
@@ -104,6 +105,7 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
   props.isLdapStrategySetup = passportService.isLdapStrategySetup;
   props.isLdapStrategySetup = passportService.isLdapStrategySetup;
   props.isLdapSetupFailed = configManager.getConfig('crowi', 'security:passport-ldap:isEnabled') && !props.isLdapStrategySetup;
   props.isLdapSetupFailed = configManager.getConfig('crowi', 'security:passport-ldap:isEnabled') && !props.isLdapStrategySetup;
   props.registrationWhiteList = configManager.getConfig('crowi', 'security:registrationWhiteList');
   props.registrationWhiteList = configManager.getConfig('crowi', 'security:registrationWhiteList');
+  props.isEmailAuthenticationEnabled = configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled');
 }
 }
 
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {

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

@@ -1281,6 +1281,10 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
       return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
     }
     }
 
 
+    if (page.isEmpty && !isRecursively) {
+      return res.json(ApiResponse.error('Empty pages cannot be single deleted', 'single_deletion_empty_pages'));
+    }
+
     let creator;
     let creator;
     if (page.isEmpty) {
     if (page.isEmpty) {
       // If empty, the creator is inherited from the closest non-empty ancestor page.
       // If empty, the creator is inherited from the closest non-empty ancestor page.

+ 16 - 4
packages/app/src/server/service/page.ts

@@ -2010,18 +2010,30 @@ class PageService {
     const includeEmpty = true;
     const includeEmpty = true;
     const originPage = await Page.findByPath(newPath, includeEmpty);
     const originPage = await Page.findByPath(newPath, includeEmpty);
 
 
-    // throw if any page already exists
-    if (originPage != null) {
+    // throw if any page already exists when recursively operation
+    if (originPage != null && (!originPage.isEmpty || isRecursively)) {
       throw new PathAlreadyExistsError('already_exists', originPage.path);
       throw new PathAlreadyExistsError('already_exists', originPage.path);
     }
     }
 
 
     // 2. Revert target
     // 2. Revert target
     const parent = await this.getParentAndFillAncestorsByUser(user, newPath);
     const parent = await this.getParentAndFillAncestorsByUser(user, newPath);
-    const updatedPage = await Page.findByIdAndUpdate(page._id, {
+    const shouldReplace = originPage != null && originPage.isEmpty;
+    let updatedPage = await Page.findByIdAndUpdate(page._id, {
       $set: {
       $set: {
-        path: newPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null, parent: parent._id, descendantCount: 0,
+        path: newPath,
+        status: Page.STATUS_PUBLISHED,
+        lastUpdateUser: user._id,
+        deleteUser: null,
+        deletedAt: null,
+        parent: parent._id,
+        descendantCount: shouldReplace ? originPage.descendantCount : 0,
       },
       },
     }, { new: true });
     }, { new: true });
+
+    if (shouldReplace) {
+      updatedPage = await Page.replaceTargetWithPage(originPage, updatedPage, true);
+    }
+
     await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: false } });
     await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: false } });
 
 
     this.pageEvent.emit('revert', page, user);
     this.pageEvent.emit('revert', page, user);

+ 13 - 25
packages/app/src/stores/context.tsx

@@ -1,4 +1,4 @@
-import { IUser } from '@growi/core';
+import { IUser, pagePathUtils } from '@growi/core';
 import { HtmlElementNode } from 'rehype-toc';
 import { HtmlElementNode } from 'rehype-toc';
 import { Key, SWRResponse } from 'swr';
 import { Key, SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
@@ -80,14 +80,6 @@ export const useIsIdenticalPath = (initialData?: boolean): SWRResponse<boolean,
   return useStaticSWR<boolean, Error>('isIdenticalPath', initialData, { fallbackData: false });
   return useStaticSWR<boolean, Error>('isIdenticalPath', initialData, { fallbackData: false });
 };
 };
 
 
-export const useIsUserPage = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isUserPage', initialData, { fallbackData: false });
-};
-
-export const useIsTrashPage = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isTrashPage', initialData, { fallbackData: false });
-};
-
 // export const useIsNotCreatable = (initialData?: boolean): SWRResponse<boolean, Error> => {
 // export const useIsNotCreatable = (initialData?: boolean): SWRResponse<boolean, Error> => {
 //   return useStaticSWR<boolean, Error>('isNotCreatable', initialData, { fallbackData: false });
 //   return useStaticSWR<boolean, Error>('isNotCreatable', initialData, { fallbackData: false });
 // };
 // };
@@ -127,10 +119,6 @@ export const useRegistrationWhiteList = (initialData?: Nullable<string[]>): SWRR
   return useStaticSWR<Nullable<string[]>, Error>('registrationWhiteList', initialData);
   return useStaticSWR<Nullable<string[]>, Error>('registrationWhiteList', initialData);
 };
 };
 
 
-export const useRevisionIdHackmdSynced = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('revisionIdHackmdSynced', initialData);
-};
-
 export const useDrawioUri = (initialData?: string): SWRResponse<string, Error> => {
 export const useDrawioUri = (initialData?: string): SWRResponse<string, Error> => {
   return useStaticSWR('drawioUri', initialData, { fallbackData: 'https://embed.diagrams.net/' });
   return useStaticSWR('drawioUri', initialData, { fallbackData: 'https://embed.diagrams.net/' });
 };
 };
@@ -147,14 +135,6 @@ export const useDeleteUsername = (initialData?: Nullable<any>): SWRResponse<Null
   return useStaticSWR<Nullable<any>, Error>('deleteUsername', initialData);
   return useStaticSWR<Nullable<any>, Error>('deleteUsername', initialData);
 };
 };
 
 
-export const usePageIdOnHackmd = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('pageIdOnHackmd', initialData);
-};
-
-export const useHasDraftOnHackmd = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('hasDraftOnHackmd', initialData);
-};
-
 export const useIsSearchPage = (initialData?: Nullable<any>) : SWRResponse<Nullable<any>, Error> => {
 export const useIsSearchPage = (initialData?: Nullable<any>) : SWRResponse<Nullable<any>, Error> => {
   return useStaticSWR<Nullable<any>, Error>('isSearchPage', initialData);
   return useStaticSWR<Nullable<any>, Error>('isSearchPage', initialData);
 };
 };
@@ -293,12 +273,11 @@ export const useIsEditable = (): SWRResponse<boolean, Error> => {
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isForbidden } = useIsForbidden();
   const { data: isForbidden } = useIsForbidden();
   const { data: isIdenticalPath } = useIsIdenticalPath();
   const { data: isIdenticalPath } = useIsIdenticalPath();
-  const { data: isTrashPage } = useIsTrashPage();
 
 
   return useSWRImmutable(
   return useSWRImmutable(
-    ['isEditable', isGuestUser, isForbidden, isIdenticalPath, isTrashPage],
-    (key: Key, isGuestUser: boolean, isForbidden: boolean, isIdenticalPath: boolean, isTrashPage: boolean) => {
-      return (!isTrashPage && !isForbidden && !isIdenticalPath && !isGuestUser);
+    ['isEditable', isGuestUser, isForbidden, isIdenticalPath],
+    (key: Key, isGuestUser: boolean, isForbidden: boolean, isIdenticalPath: boolean) => {
+      return (!isForbidden && !isIdenticalPath && !isGuestUser);
     },
     },
   );
   );
 };
 };
@@ -308,3 +287,12 @@ export const useCurrentPageTocNode = (): SWRResponse<HtmlElementNode, any> => {
 
 
   return useStaticSWR(['currentPageTocNode', currentPagePath]);
   return useStaticSWR(['currentPageTocNode', currentPagePath]);
 };
 };
+
+export const useIsTrashPage = (): SWRResponse<boolean, Error> => {
+  const { data: pagePath } = useCurrentPagePath();
+
+  return useSWRImmutable(
+    pagePath == null ? null : ['isTrashPage', pagePath],
+    (key: Key, pagePath: string) => pagePathUtils.isTrashPage(pagePath),
+  );
+};

+ 21 - 0
packages/app/src/stores/hackmd.ts

@@ -0,0 +1,21 @@
+import { SWRResponse } from 'swr';
+import { useStaticSWR } from './use-static-swr';
+
+type Nullable<T> = T | null;
+
+export const usePageIdOnHackmd = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
+  return useStaticSWR<Nullable<any>, Error>('pageIdOnHackmd', initialData);
+};
+
+
+export const useHasDraftOnHackmd = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
+  return useStaticSWR<Nullable<any>, Error>('hasDraftOnHackmd', initialData);
+};
+
+export const useRevisionIdHackmdSynced = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
+  return useStaticSWR<Nullable<any>, Error>('revisionIdHackmdSynced', initialData);
+};
+
+export const useRemoteRevisionId = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
+  return useStaticSWR<Nullable<any>, Error>('remoteRevisionId', initialData);
+};

+ 10 - 7
packages/app/src/stores/ui.tsx

@@ -405,18 +405,21 @@ export const useIsAbleToShowTrashPageManagementButtons = (): SWRResponse<boolean
 export const useIsAbleToShowPageManagement = (): SWRResponse<boolean, Error> => {
 export const useIsAbleToShowPageManagement = (): SWRResponse<boolean, Error> => {
   const key = 'isAbleToShowPageManagement';
   const key = 'isAbleToShowPageManagement';
   const { data: currentPageId } = useCurrentPageId();
   const { data: currentPageId } = useCurrentPageId();
-  const { data: isTrashPage } = useIsTrashPage();
-  const { data: isSharedUser } = useIsSharedUser();
+  const { data: _isTrashPage } = useIsTrashPage();
+  const { data: _isSharedUser } = useIsSharedUser();
   const { data: isNotFound } = useIsNotFound();
   const { data: isNotFound } = useIsNotFound();
 
 
   const pageId = currentPageId;
   const pageId = currentPageId;
-  const includesUndefined = [pageId, isTrashPage, isSharedUser, isNotFound].some(v => v === undefined);
-  const isPageExist = (pageId != null) && !isNotFound;
-  const isEmptyPage = (pageId != null) && isNotFound;
+  const includesUndefined = [pageId, _isTrashPage, _isSharedUser, isNotFound].some(v => v === undefined);
+  const isPageExist = (pageId != null) && isNotFound === false;
+  const isEmptyPage = (pageId != null) && isNotFound === true;
+  const isTrashPage = isPageExist && _isTrashPage === true;
+  const isSharedUser = isPageExist && _isSharedUser === true;
 
 
   return useSWRImmutable(
   return useSWRImmutable(
-    includesUndefined ? null : [key, pageId],
-    () => (isPageExist && !isTrashPage && !isSharedUser) || (isEmptyPage != null && isEmptyPage),
+    includesUndefined ? null : [key, pageId, isPageExist, isEmptyPage, isTrashPage, isSharedUser],
+    // eslint-disable-next-line max-len
+    (key: string, pageId: string, isPageExist: boolean, isTrashPage: boolean, isSharedUser: boolean) => (isPageExist && !isTrashPage && !isSharedUser) || isEmptyPage,
   );
   );
 };
 };
 
 

+ 5 - 0
packages/app/src/styles/bootstrap/_override.scss

@@ -167,3 +167,8 @@ fieldset[disabled] .btn {
   word-break: break-word;
   word-break: break-word;
   overflow-wrap: break-word;
   overflow-wrap: break-word;
 }
 }
+
+// prevent tooltip flickering (flashing) on hover
+.tooltip {
+  pointer-events: none;
+}

+ 1 - 1
packages/app/test/cypress/integration/20-basic-features/access-to-pagelist.spec.ts

@@ -90,7 +90,7 @@ context('Access to timeline', () => {
       cy.get('.nav-title > li').eq(1).find('a').click();
       cy.get('.nav-title > li').eq(1).find('a').click();
       cy.get('button.close').eq(0).click();
       cy.get('button.close').eq(0).click();
     });
     });
-    cy.get('.modal').should('be.visible').scrollTo('top');
+    cy.get('.modal').should('be.visible');
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(500); // wait for loading wiki
     cy.wait(500); // wait for loading wiki
     cy.screenshot(`${ssPrefix}2-timeline-list-fullscreen`, {capture: 'viewport'});
     cy.screenshot(`${ssPrefix}2-timeline-list-fullscreen`, {capture: 'viewport'});

+ 7 - 6
packages/app/test/cypress/integration/20-basic-features/click-page-icons.spec.ts

@@ -14,13 +14,13 @@ context('Click page icons button', () => {
     cy.visit('/Sandbox');
     cy.visit('/Sandbox');
     cy.get('#grw-subnav-container').within(() => {
     cy.get('#grw-subnav-container').within(() => {
       // Subscribe
       // Subscribe
-      cy.get('#subscribe-button').eq(0).click({force: true});
-      cy.get('#subscribe-button').eq(0).should('have.class', 'active');
+      cy.get('#subscribe-button').click({force: true});
+      cy.get('#subscribe-button').should('have.class', 'active');
       cy.screenshot(`${ssPrefix}1-subscribe-page`);
       cy.screenshot(`${ssPrefix}1-subscribe-page`);
 
 
       // Unsubscribe
       // Unsubscribe
-      cy.get('#subscribe-button.active').eq(0).click({force: true});
-      cy.get('#subscribe-button').eq(0).should('not.have.class', 'active');
+      cy.get('#subscribe-button.active').click({force: true});
+      cy.get('#subscribe-button').should('not.have.class', 'active');
       cy.screenshot(`${ssPrefix}2-unsubscribe-page`);
       cy.screenshot(`${ssPrefix}2-unsubscribe-page`);
     });
     });
   });
   });
@@ -78,9 +78,10 @@ context('Click page icons button', () => {
   it('Successfully display list of "seen by user"', () => {
   it('Successfully display list of "seen by user"', () => {
     cy.visit('/Sandbox');
     cy.visit('/Sandbox');
     cy.get('#grw-subnav-container').within(() => {
     cy.get('#grw-subnav-container').within(() => {
-      cy.get('#btn-seen-user').click({force: true});
+      cy.get('div.grw-seen-user-info > button#btn-seen-user').click({force: true});
     });
     });
-    cy.get('.user-list-popover').should('be.visible');
+    // TODO:
+    // cy.get('div.user-list-popover').should('be.visible');
 
 
     cy.get('#grw-subnav-container').within(() => {
     cy.get('#grw-subnav-container').within(() => {
       cy.screenshot(`${ssPrefix}11-seen-user-list`);
       cy.screenshot(`${ssPrefix}11-seen-user-list`);

+ 20 - 15
packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts

@@ -72,7 +72,7 @@ context('Modal for page operation', () => {
     });
     });
     cy.getByTestid('page-editor').should('be.visible');
     cy.getByTestid('page-editor').should('be.visible');
     cy.getByTestid('save-page-btn').click();
     cy.getByTestid('save-page-btn').click();
-    cy.get('layout-root').should('not.have.class', 'editing');
+    cy.get('.layout-root').should('not.have.class', 'editing');
 
 
     cy.getByTestid('grw-contextual-sub-nav').should('be.visible');
     cy.getByTestid('grw-contextual-sub-nav').should('be.visible');
     cy.screenshot(`${ssPrefix}create-page-under-specific-page`);
     cy.screenshot(`${ssPrefix}create-page-under-specific-page`);
@@ -115,8 +115,8 @@ context('Modal for page operation', () => {
     cy.visit('/Sandbox/Bootstrap4', {  });
     cy.visit('/Sandbox/Bootstrap4', {  });
 
 
     cy.get('#grw-subnav-container').within(() => {
     cy.get('#grw-subnav-container').within(() => {
-      cy.getByTestid('open-page-item-control-btn').click();
-      cy.getByTestid('open-page-duplicate-modal-btn').click();
+      cy.getByTestid('open-page-item-control-btn').click({force: true});
+      cy.getByTestid('open-page-duplicate-modal-btn').click({force: true});
     });
     });
 
 
     cy.getByTestid('page-duplicate-modal').should('be.visible').screenshot(`${ssPrefix}-duplicate-bootstrap4`);
     cy.getByTestid('page-duplicate-modal').should('be.visible').screenshot(`${ssPrefix}-duplicate-bootstrap4`);
@@ -126,7 +126,7 @@ context('Modal for page operation', () => {
     cy.visit('/Sandbox/Bootstrap4', {  });
     cy.visit('/Sandbox/Bootstrap4', {  });
 
 
     cy.get('#grw-subnav-container').within(() => {
     cy.get('#grw-subnav-container').within(() => {
-      cy.getByTestid('open-page-item-control-btn').click();
+      cy.getByTestid('open-page-item-control-btn').click({force: true});
       cy.getByTestid('open-page-move-rename-modal-btn').click({force: true});
       cy.getByTestid('open-page-move-rename-modal-btn').click({force: true});
     });
     });
 
 
@@ -178,18 +178,21 @@ context('Page Accessories Modal', () => {
   it('Page History is shown successfully', () => {
   it('Page History is shown successfully', () => {
      cy.visit('/Sandbox/Bootstrap4', {  });
      cy.visit('/Sandbox/Bootstrap4', {  });
      cy.get('#grw-subnav-container').within(() => {
      cy.get('#grw-subnav-container').within(() => {
-       cy.getByTestid('open-page-item-control-btn').click();
-       cy.getByTestid('open-page-accessories-modal-btn-with-history-tab').click();
+      cy.getByTestid('open-page-item-control-btn').within(() => {
+        cy.get('button.btn-page-item-control').click({force: true});
+      });
+      cy.getByTestid('open-page-accessories-modal-btn-with-history-tab').click({force: true});
     });
     });
 
 
-     cy.getByTestid('page-accessories-modal').should('be.visible')
      cy.getByTestid('page-history').should('be.visible')
      cy.getByTestid('page-history').should('be.visible')
      cy.screenshot(`${ssPrefix}-open-page-history-bootstrap4`);
      cy.screenshot(`${ssPrefix}-open-page-history-bootstrap4`);
   });
   });
   it('Page Attachment Data is shown successfully', () => {
   it('Page Attachment Data is shown successfully', () => {
      cy.visit('/Sandbox/Bootstrap4', {  });
      cy.visit('/Sandbox/Bootstrap4', {  });
      cy.get('#grw-subnav-container').within(() => {
      cy.get('#grw-subnav-container').within(() => {
-       cy.getByTestid('open-page-item-control-btn').click();
+      cy.getByTestid('open-page-item-control-btn').within(() => {
+        cy.get('button.btn-page-item-control').click({force: true});
+      });
        cy.getByTestid('open-page-accessories-modal-btn-with-attachment-data-tab').click();
        cy.getByTestid('open-page-accessories-modal-btn-with-attachment-data-tab').click();
     });
     });
 
 
@@ -200,7 +203,9 @@ context('Page Accessories Modal', () => {
   it('Share Link Management is shown successfully', () => {
   it('Share Link Management is shown successfully', () => {
     cy.visit('/Sandbox/Bootstrap4', { });
     cy.visit('/Sandbox/Bootstrap4', { });
     cy.get('#grw-subnav-container').within(() => {
     cy.get('#grw-subnav-container').within(() => {
-      cy.getByTestid('open-page-item-control-btn').click();
+      cy.getByTestid('open-page-item-control-btn').within(() => {
+        cy.get('button.btn-page-item-control').click({force: true});
+      });
       cy.getByTestid('open-page-accessories-modal-btn-with-share-link-management-data-tab').click();
       cy.getByTestid('open-page-accessories-modal-btn-with-share-link-management-data-tab').click();
    });
    });
 
 
@@ -248,7 +253,7 @@ context('Tag Oprations', () =>{
     });
     });
 
 
     cy.get('.toast').should('be.visible').trigger('mouseover');
     cy.get('.toast').should('be.visible').trigger('mouseover');
-    cy.get('.grw-taglabels-container > form > a').contains(tag).should('exist');
+    cy.get('.grw-taglabels-container > .grw-tag-labels > a', { timeout: 10000 }).contains(tag).should('exist');
     /* eslint-disable cypress/no-unnecessary-waiting */
     /* eslint-disable cypress/no-unnecessary-waiting */
     cy.wait(150); // wait for toastr to change its color occured by mouseover
     cy.wait(150); // wait for toastr to change its color occured by mouseover
     cy.screenshot(`${ssPrefix}4-click-done`, {capture: 'viewport'});
     cy.screenshot(`${ssPrefix}4-click-done`, {capture: 'viewport'});
@@ -260,11 +265,11 @@ context('Tag Oprations', () =>{
     const tag = 'we';
     const tag = 'we';
     const newPageName = 'our';
     const newPageName = 'our';
     cy.visit('/');
     cy.visit('/');
-    cy.get('.grw-taglabels-container > form > a').contains(tag).click();
+    cy.get('.grw-taglabels-container > .grw-tag-labels > a', { timeout: 10000 }).contains(tag).click();
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    // cy.get('#wiki').should('be.visible');
     // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
     // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
     cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
     cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
     cy.screenshot(`${ssPrefix}1-click-tag-name`, {capture: 'viewport'});
     cy.screenshot(`${ssPrefix}1-click-tag-name`, {capture: 'viewport'});
@@ -284,7 +289,7 @@ context('Tag Oprations', () =>{
       cy.get('.modal-footer > button.btn').click();
       cy.get('.modal-footer > button.btn').click();
     });
     });
     cy.visit(`/${newPageName}`);
     cy.visit(`/${newPageName}`);
-    cy.getByTestid('wiki').should('exist');
+    // cy.getByTestid('wiki').should('exist');
     cy.screenshot(`${ssPrefix}4-duplicated-page`, {capture: 'viewport'});
     cy.screenshot(`${ssPrefix}4-duplicated-page`, {capture: 'viewport'});
   });
   });
 
 
@@ -295,11 +300,11 @@ context('Tag Oprations', () =>{
     const newPageName = '/ourus';
     const newPageName = '/ourus';
 
 
     cy.visit('/');
     cy.visit('/');
-    cy.get('.grw-taglabels-container > form > a').contains(tag).click();
+    cy.get('.grw-taglabels-container > .grw-tag-labels > a', { timeout: 10000 }).contains(tag).click();
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
-    cy.get('#wiki').should('be.visible');
+    // cy.get('#wiki').should('be.visible');
     cy.screenshot(`${ssPrefix}1-click-tag-name`, {capture: 'viewport'});
     cy.screenshot(`${ssPrefix}1-click-tag-name`, {capture: 'viewport'});
 
 
     cy.getByTestid('search-result-list').within(() => {
     cy.getByTestid('search-result-list').within(() => {

+ 2 - 5
packages/app/test/cypress/integration/21-basic-features-for-guest/access-to-page.spec.ts

@@ -12,7 +12,7 @@ context('Access to page by guest', () => {
     cy.collapseSidebar(true, true);
     cy.collapseSidebar(true, true);
 
 
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     // eslint-disable-next-line cypress/no-unnecessary-waiting
-    cy.wait(500);
+    // cy.wait(500);
 
 
     // hide fab // disable fab for sticky-events warning
     // hide fab // disable fab for sticky-events warning
     // cy.getByTestid('grw-fab-container').invoke('attr', 'style', 'display: none');
     // cy.getByTestid('grw-fab-container').invoke('attr', 'style', 'display: none');
@@ -24,10 +24,7 @@ context('Access to page by guest', () => {
     cy.visit('/Sandbox/Math');
     cy.visit('/Sandbox/Math');
     cy.collapseSidebar(true, true);
     cy.collapseSidebar(true, true);
 
 
-    cy.get('mjx-container').should('be.visible');
-    // eslint-disable-next-line cypress/no-unnecessary-waiting
-    cy.wait(2000); // wait for 2 seconds for MathJax.typesetPromise();
-
+    cy.get('.math').should('be.visible');
     cy.screenshot(`${ssPrefix}-sandbox-math`);
     cy.screenshot(`${ssPrefix}-sandbox-math`);
   });
   });
 
 

+ 11 - 11
packages/app/test/cypress/integration/50-sidebar/access-to-side-bar.spec.ts

@@ -60,7 +60,7 @@ context('Access to sidebar', () => {
     cy.get('.CodeMirror textarea').type(content, {force: true});
     cy.get('.CodeMirror textarea').type(content, {force: true});
     cy.screenshot(`${ssPrefix}custom-sidebar-2-custom-sidebar-editor`);
     cy.screenshot(`${ssPrefix}custom-sidebar-2-custom-sidebar-editor`);
     cy.getByTestid('save-page-btn').click();
     cy.getByTestid('save-page-btn').click();
-    cy.get('layout-root').should('not.have.class', 'editing');
+    cy.get('.layout-root', { timeout: 10000 }).should('not.have.class', 'editing');
 
 
     // What to do when UserUISettings is not saved in time
     // What to do when UserUISettings is not saved in time
     cy.getByTestid('grw-sidebar-nav-primary-custom-sidebar').then(($el) => {
     cy.getByTestid('grw-sidebar-nav-primary-custom-sidebar').then(($el) => {
@@ -87,7 +87,7 @@ context('Access to sidebar', () => {
     cy.get('.grw-pagetree-triangle-btn').eq(0).click();
     cy.get('.grw-pagetree-triangle-btn').eq(0).click();
 
 
     cy.get('.grw-pagetree-item-children').eq(0).within(() => {
     cy.get('.grw-pagetree-item-children').eq(0).within(() => {
-      cy.getByTestid('open-page-item-control-btn').find('button').eq(0).invoke('css','display','block').click()
+      cy.getByTestid('open-page-item-control-btn').find('button').eq(0).invoke('css','display','block').click();
     });
     });
 
 
     cy.screenshot(`${ssPrefix}page-tree-3-click-three-dots-menu`);
     cy.screenshot(`${ssPrefix}page-tree-3-click-three-dots-menu`);
@@ -98,7 +98,7 @@ context('Access to sidebar', () => {
 
 
 
 
     cy.get('.grw-pagetree-item-children').eq(0).within(() => {
     cy.get('.grw-pagetree-item-children').eq(0).within(() => {
-      cy.getByTestid('open-page-item-control-btn').find('button').eq(0).invoke('css','display','block').click()
+      cy.getByTestid('open-page-item-control-btn').find('button').eq(0).invoke('css','display','block').click();
     });
     });
     cy.get('.dropdown-menu.show').should('be.visible').within(() => {
     cy.get('.dropdown-menu.show').should('be.visible').within(() => {
       cy.getByTestid('open-page-duplicate-modal-btn').click();
       cy.getByTestid('open-page-duplicate-modal-btn').click();
@@ -156,14 +156,14 @@ context('Access to sidebar', () => {
     cy.screenshot(`${ssPrefix}tags-2-check-all-tags`);
     cy.screenshot(`${ssPrefix}tags-2-check-all-tags`);
   });
   });
 
 
-  it('Successfully access to My Drafts page', () => {
-    cy.visit('/');
-    cy.collapseSidebar(true);
-    cy.get('.grw-sidebar-nav-secondary-container').within(() => {
-      cy.get('a[href*="/me/drafts"]').click();
-    });
-    cy.screenshot(`${ssPrefix}access-to-drafts-page`);
-  });
+  // it('Successfully access to My Drafts page', () => {
+  //   cy.visit('/');
+  //   cy.collapseSidebar(true);
+  //   cy.get('.grw-sidebar-nav-secondary-container').within(() => {
+  //     cy.get('a[href*="/me/drafts"]').click();
+  //   });
+  //   cy.screenshot(`${ssPrefix}access-to-drafts-page`);
+  // });
   it('Successfully access to Growi Docs page', () => {
   it('Successfully access to Growi Docs page', () => {
     cy.visit('/');
     cy.visit('/');
     cy.get('.grw-sidebar-nav-secondary-container').within(() => {
     cy.get('.grw-sidebar-nav-secondary-container').within(() => {

+ 1 - 1
packages/app/test/cypress/support/commands.ts

@@ -46,7 +46,7 @@ Cypress.Commands.add('collapseSidebar', (isCollapsed, force=false) => {
     return;
     return;
   }
   }
 
 
-  const isGrowiPage = Cypress.$('body.growi').length > 0;
+  const isGrowiPage = Cypress.$('div.growi').length > 0;
   if (!isGrowiPage) {
   if (!isGrowiPage) {
     cy.visit('/page-to-toggle-sidebar-collapsed');
     cy.visit('/page-to-toggle-sidebar-collapsed');
   }
   }

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

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

+ 1 - 1
packages/core/package.json

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

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

@@ -39,3 +39,11 @@ export type IUserGroup = {
 export type IUserHasId = IUser & HasObjectId;
 export type IUserHasId = IUser & HasObjectId;
 export type IUserGroupHasId = IUserGroup & HasObjectId;
 export type IUserGroupHasId = IUserGroup & HasObjectId;
 export type IUserGroupRelationHasId = IUserGroupRelation & HasObjectId;
 export type IUserGroupRelationHasId = IUserGroupRelation & HasObjectId;
+
+export type IAdminExternalAccount = {
+  _id: string,
+  providerType: string,
+  accountId: string,
+  user: IUser,
+  createdAt: Date,
+}

+ 1 - 1
packages/hackmd/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/hackmd",
   "name": "@growi/hackmd",
-  "version": "6.0.0-RC.7",
+  "version": "6.0.0-RC.8",
   "description": "GROWI js and css files to use hackmd",
   "description": "GROWI js and css files to use hackmd",
   "license": "MIT",
   "license": "MIT",
   "main": "dist/index.js",
   "main": "dist/index.js",

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

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

+ 4 - 4
packages/plugin-lsx/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/plugin-lsx",
   "name": "@growi/plugin-lsx",
-  "version": "6.0.0-RC.7",
+  "version": "6.0.0-RC.8",
   "description": "GROWI plugin to list pages",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "license": "MIT",
   "keywords": ["growi", "growi-plugin"],
   "keywords": ["growi", "growi-plugin"],
@@ -23,9 +23,9 @@
     "test": ""
     "test": ""
   },
   },
   "dependencies": {
   "dependencies": {
-    "@growi/core": "^6.0.0-RC.7",
-    "@growi/remark-growi-plugin": "^6.0.0-RC.7",
-    "@growi/ui": "^6.0.0-RC.7"
+    "@growi/core": "^6.0.0-RC.8",
+    "@growi/remark-growi-plugin": "^6.0.0-RC.8",
+    "@growi/ui": "^6.0.0-RC.8"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "eslint-plugin-regex": "^1.8.0",
     "eslint-plugin-regex": "^1.8.0",

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

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

+ 1 - 1
packages/slack/package.json

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

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

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/slackbot-proxy",
   "name": "@growi/slackbot-proxy",
-  "version": "6.0.0-RC.7",
+  "version": "6.0.0-RC.8",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -26,7 +26,7 @@
   },
   },
   "dependencies": {
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^6.0.0-RC.7",
+    "@growi/slack": "^6.0.0-RC.8",
     "@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",

+ 2 - 2
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/ui",
   "name": "@growi/ui",
-  "version": "6.0.0-RC.7",
+  "version": "6.0.0-RC.8",
   "description": "GROWI UI Libraries",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "license": "MIT",
   "keywords": ["growi"],
   "keywords": ["growi"],
@@ -17,7 +17,7 @@
     "test": "jest --verbose"
     "test": "jest --verbose"
   },
   },
   "dependencies": {
   "dependencies": {
-    "@growi/core": "^6.0.0-RC.7"
+    "@growi/core": "^6.0.0-RC.8"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "eslint-plugin-regex": "^1.8.0",
     "eslint-plugin-regex": "^1.8.0",

+ 0 - 104
packages/ui/src/components/User/UserPicture.jsx

@@ -1,104 +0,0 @@
-import React from 'react';
-
-import { pagePathUtils } from '@growi/core';
-import PropTypes from 'prop-types';
-import { UncontrolledTooltip } from 'reactstrap';
-
-
-const { userPageRoot } = pagePathUtils;
-
-
-const DEFAULT_IMAGE = '/images/icons/user.svg';
-
-export class UserPicture extends React.Component {
-
-  getClassName() {
-    const className = ['rounded-circle', 'picture'];
-    // size
-    if (this.props.size) {
-      className.push(`picture-${this.props.size}`);
-    }
-
-    return className.join(' ');
-  }
-
-  renderForNull() {
-    return (
-      <img
-        src={DEFAULT_IMAGE}
-        alt="someone"
-        className={this.getClassName()}
-      />
-    );
-  }
-
-  RootElmWithoutLink = (props) => {
-    return <span {...props}>{props.children}</span>;
-  };
-
-  RootElmWithLink = (props) => {
-    const { user } = this.props;
-    const href = userPageRoot(user);
-    // Using <span> tag here instead of <a> tag because UserPicture is used in SearchResultList which is essentially a anchor tag.
-    // Nested anchor tags causes a warning.
-    // https://stackoverflow.com/questions/13052598/creating-anchor-tag-inside-anchor-taga
-    return <span onClick={() => { window.location.href = href }} {...props}>{props.children}</span>;
-  };
-
-  withTooltip = (RootElm) => {
-    const { user } = this.props;
-    const id = `user-picture-${Math.random().toString(32).substring(2)}`;
-
-    return props => (
-      <>
-        <RootElm id={id}>{props.children}</RootElm>
-        <UncontrolledTooltip placement="bottom" target={id} delay={0} fade={false}>
-          @{user.username}<br />
-          {user.name}
-        </UncontrolledTooltip>
-      </>
-    );
-  };
-
-  render() {
-    const user = this.props.user;
-
-    if (user == null) {
-      return this.renderForNull();
-    }
-
-    const { noLink, noTooltip } = this.props;
-
-    // determine RootElm
-    let RootElm = noLink ? this.RootElmWithoutLink : this.RootElmWithLink;
-    if (!noTooltip) {
-      RootElm = this.withTooltip(RootElm);
-    }
-
-    const userPictureSrc = user.imageUrlCached || DEFAULT_IMAGE;
-
-    return (
-      <RootElm>
-        <img
-          src={userPictureSrc}
-          alt={user.username}
-          className={this.getClassName()}
-        />
-      </RootElm>
-    );
-  }
-
-}
-
-UserPicture.propTypes = {
-  user: PropTypes.object,
-  size: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']),
-  noLink: PropTypes.bool,
-  noTooltip: PropTypes.bool,
-};
-
-UserPicture.defaultProps = {
-  size: null,
-  noLink: false,
-  noTooltip: false,
-};

+ 120 - 0
packages/ui/src/components/User/UserPicture.tsx

@@ -0,0 +1,120 @@
+import React, {
+  forwardRef, useCallback, useRef,
+} from 'react';
+
+import type { Ref, IUser } from '@growi/core';
+import { pagePathUtils } from '@growi/core';
+import dynamic from 'next/dynamic';
+import { useRouter } from 'next/router';
+import type { UncontrolledTooltipProps } from 'reactstrap';
+
+const UncontrolledTooltip = dynamic<UncontrolledTooltipProps>(() => import('reactstrap').then(mod => mod.UncontrolledTooltip), { ssr: false });
+
+const { userPageRoot } = pagePathUtils;
+
+const DEFAULT_IMAGE = '/images/icons/user.svg';
+
+
+type UserPictureRootProps = {
+  user: Partial<IUser>,
+  className?: string,
+  children?: React.ReactNode,
+}
+
+const UserPictureRootWithoutLink = forwardRef<HTMLSpanElement, UserPictureRootProps>((props, ref) => {
+  return <span ref={ref} className={props.className}>{props.children}</span>;
+});
+
+const UserPictureRootWithLink = forwardRef<HTMLSpanElement, UserPictureRootProps>((props, ref) => {
+  const router = useRouter();
+
+  const { user } = props;
+  const href = userPageRoot(user);
+
+  const clickHandler = useCallback(() => {
+    router.push(href);
+  }, [href, router]);
+
+  // Using <span> tag here instead of <a> tag because UserPicture is used in SearchResultList which is essentially a anchor tag.
+  // Nested anchor tags causes a warning.
+  // https://stackoverflow.com/questions/13052598/creating-anchor-tag-inside-anchor-taga
+  return <span ref={ref} className={props.className} onClick={clickHandler} style={{ cursor: 'pointer' }}>{props.children}</span>;
+});
+
+
+// wrapper with Tooltip
+const withTooltip = (UserPictureSpanElm: React.ForwardRefExoticComponent<UserPictureRootProps & React.RefAttributes<HTMLSpanElement>>) => {
+  return (props: UserPictureRootProps) => {
+    const { user } = props;
+
+    const userPictureRef = useRef<HTMLSpanElement>(null);
+
+    return (
+      <>
+        <UserPictureSpanElm ref={userPictureRef} user={user}>{props.children}</UserPictureSpanElm>
+        <UncontrolledTooltip placement="bottom" target={userPictureRef} delay={0} fade={false}>
+          @{user.username}<br />
+          {user.name}
+        </UncontrolledTooltip>
+      </>
+    );
+  };
+};
+
+
+/**
+ * type guard to determine whether the specified object is IUser
+ */
+const isUserObj = (obj: Partial<IUser> | Ref<IUser>): obj is Partial<IUser> => {
+  return typeof obj !== 'string' && 'username' in obj;
+};
+
+
+type Props = {
+  user?: Partial<IUser> | Ref<IUser> | null,
+  size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl',
+  noLink?: boolean,
+  noTooltip?: boolean,
+};
+
+export const UserPicture = React.memo((props: Props): JSX.Element => {
+
+  const {
+    user, size, noLink, noTooltip,
+  } = props;
+
+  const classNames = ['rounded-circle', 'picture'];
+  if (size != null) {
+    classNames.push(`picture-${size}`);
+  }
+  const className = classNames.join(' ');
+
+  if (user == null || !isUserObj(user)) {
+    return (
+      <img
+        src={DEFAULT_IMAGE}
+        alt="someone"
+        className={className}
+      />
+    );
+  }
+
+  // determine RootElm
+  const UserPictureSpanElm = noLink ? UserPictureRootWithoutLink : UserPictureRootWithLink;
+  const UserPictureRootElm = noTooltip
+    ? UserPictureSpanElm
+    : withTooltip(UserPictureSpanElm);
+
+  const userPictureSrc = user.imageUrlCached ?? DEFAULT_IMAGE;
+
+  return (
+    <UserPictureRootElm user={user}>
+      <img
+        src={userPictureSrc}
+        alt={user.username}
+        className={className}
+      />
+    </UserPictureRootElm>
+  );
+});
+UserPicture.displayName = 'UserPicture';