Taichi Masuyama 3 лет назад
Родитель
Сommit
11f13479f8
100 измененных файлов с 2783 добавлено и 2065 удалено
  1. 50 1
      CHANGELOG.md
  2. 1 1
      lerna.json
  3. 1 1
      package.json
  4. 5 0
      packages/app/.env.development
  5. 2 2
      packages/app/docker/README.md
  6. 8 7
      packages/app/package.json
  7. 16 0
      packages/app/public/static/locales/en_US/admin/admin.json
  8. 2 0
      packages/app/public/static/locales/en_US/translation.json
  9. 16 0
      packages/app/public/static/locales/ja_JP/admin/admin.json
  10. 2 0
      packages/app/public/static/locales/ja_JP/translation.json
  11. 16 0
      packages/app/public/static/locales/zh_CN/admin/admin.json
  12. 2 0
      packages/app/public/static/locales/zh_CN/translation.json
  13. 2 0
      packages/app/src/client/admin.jsx
  14. 2 21
      packages/app/src/client/app.jsx
  15. 3 0
      packages/app/src/client/base.jsx
  16. 0 167
      packages/app/src/client/services/CommentContainer.js
  17. 7 7
      packages/app/src/client/services/ContextExtractor.tsx
  18. 0 1
      packages/app/src/client/services/PageContainer.js
  19. 0 172
      packages/app/src/client/services/PageHistoryContainer.js
  20. 0 186
      packages/app/src/client/services/PersonalContainer.js
  21. 0 113
      packages/app/src/client/services/RevisionComparerContainer.js
  22. 34 0
      packages/app/src/client/services/ShowPageAccessoriesModal.tsx
  23. 47 0
      packages/app/src/components/Admin/AuditLog/ActivityTable.tsx
  24. 26 0
      packages/app/src/components/Admin/AuditLog/AuditLogDisableMode.tsx
  25. 54 0
      packages/app/src/components/Admin/AuditLog/AuditLogSettings.tsx
  26. 66 0
      packages/app/src/components/Admin/AuditLog/DateRangePicker.tsx
  27. 122 0
      packages/app/src/components/Admin/AuditLog/SearchUsernameTypeahead.tsx
  28. 123 0
      packages/app/src/components/Admin/AuditLog/SelectActionDropdown.tsx
  29. 181 0
      packages/app/src/components/Admin/AuditLogManagement.tsx
  30. 4 0
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  31. 2 0
      packages/app/src/components/Admin/Security/LdapAuthTest.jsx
  32. 2 4
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  33. 7 2
      packages/app/src/components/BookmarkButtons.tsx
  34. 1 0
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  35. 9 9
      packages/app/src/components/Drawio.tsx
  36. 7 2
      packages/app/src/components/LikeButtons.tsx
  37. 0 116
      packages/app/src/components/Me/ApiSettings.jsx
  38. 90 0
      packages/app/src/components/Me/ApiSettings.tsx
  39. 0 152
      packages/app/src/components/Me/AssociateModal.jsx
  40. 111 0
      packages/app/src/components/Me/AssociateModal.tsx
  41. 0 181
      packages/app/src/components/Me/BasicInfoSettings.jsx
  42. 171 0
      packages/app/src/components/Me/BasicInfoSettings.tsx
  43. 0 98
      packages/app/src/components/Me/DisassociateModal.jsx
  44. 69 0
      packages/app/src/components/Me/DisassociateModal.tsx
  45. 8 18
      packages/app/src/components/Me/ExternalAccountLinkedMe.jsx
  46. 19 17
      packages/app/src/components/Me/PasswordSettings.jsx
  47. 2 1
      packages/app/src/components/Me/PersonalSettings.jsx
  48. 8 1
      packages/app/src/components/Navbar/GlobalSearch.tsx
  49. 4 6
      packages/app/src/components/Navbar/SubNavButtons.tsx
  50. 0 4
      packages/app/src/components/Page/RevisionBody.jsx
  51. 2 3
      packages/app/src/components/Page/TagsInput.tsx
  52. 4 3
      packages/app/src/components/Page/TrashPageAlert.jsx
  53. 7 7
      packages/app/src/components/PageAccessoriesModal.tsx
  54. 0 464
      packages/app/src/components/PageComment/CommentEditor.jsx
  55. 385 0
      packages/app/src/components/PageComment/CommentEditor.tsx
  56. 2 1
      packages/app/src/components/PageDeleteModal.tsx
  57. 1 2
      packages/app/src/components/PageEditor.tsx
  58. 9 11
      packages/app/src/components/PageEditor/Preview.tsx
  59. 35 59
      packages/app/src/components/PageHistory.jsx
  60. 32 33
      packages/app/src/components/PageHistory/PageRevisionTable.jsx
  61. 2 2
      packages/app/src/components/PageHistory/Revision.jsx
  62. 24 26
      packages/app/src/components/RevisionComparer/RevisionComparer.jsx
  63. 3 5
      packages/app/src/components/SubscribeButton.tsx
  64. 1 1
      packages/app/src/components/User/SeenUserInfo.tsx
  65. 470 9
      packages/app/src/interfaces/activity.ts
  66. 14 2
      packages/app/src/interfaces/comment.ts
  67. 10 0
      packages/app/src/interfaces/external-account.ts
  68. 11 1
      packages/app/src/interfaces/global.ts
  69. 3 0
      packages/app/src/interfaces/graph-viewer.ts
  70. 15 0
      packages/app/src/interfaces/interceptor-manager.ts
  71. 13 0
      packages/app/src/interfaces/mongoose-utils.ts
  72. 0 5
      packages/app/src/interfaces/page-listing-results.ts
  73. 5 0
      packages/app/src/interfaces/revision.ts
  74. 10 6
      packages/app/src/interfaces/user.ts
  75. 1 2
      packages/app/src/next-i18next.config.ts
  76. 2 2
      packages/app/src/server/crowi/dev.js
  77. 2 9
      packages/app/src/server/crowi/express-init.js
  78. 2 0
      packages/app/src/server/crowi/index.js
  79. 10 13
      packages/app/src/server/events/activity.ts
  80. 2 2
      packages/app/src/server/events/comment.ts
  81. 5 0
      packages/app/src/server/interfaces/search.ts
  82. 40 0
      packages/app/src/server/middlewares/add-activity.ts
  83. 12 12
      packages/app/src/server/middlewares/login-required.js
  84. 88 27
      packages/app/src/server/models/activity.ts
  85. 2 2
      packages/app/src/server/models/comment.js
  86. 3 0
      packages/app/src/server/models/config.ts
  87. 2 2
      packages/app/src/server/models/in-app-notification-settings.ts
  88. 9 9
      packages/app/src/server/models/in-app-notification.ts
  89. 1 1
      packages/app/src/server/models/interfaces/page-operation.ts
  90. 1 12
      packages/app/src/server/models/obsolete-page.js
  91. 1 1
      packages/app/src/server/models/page-operation.ts
  92. 5 5
      packages/app/src/server/models/subscription.ts
  93. 2 2
      packages/app/src/server/models/user.js
  94. 18 2
      packages/app/src/server/routes/admin.js
  95. 113 0
      packages/app/src/server/routes/apiv3/activity.ts
  96. 41 13
      packages/app/src/server/routes/apiv3/app-settings.js
  97. 17 6
      packages/app/src/server/routes/apiv3/bookmarks.js
  98. 32 9
      packages/app/src/server/routes/apiv3/customize-setting.js
  99. 9 1
      packages/app/src/server/routes/apiv3/export.js
  100. 3 3
      packages/app/src/server/routes/apiv3/forgot-password.js

+ 50 - 1
CHANGELOG.md

@@ -1,9 +1,58 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.0.9...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.0.11...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.0.11](https://github.com/weseek/growi/compare/v5.0.10...v5.0.11) - 2022-07-05
+
+### 💎 Features
+
+- feat: Integrate recount descendant count after paths fix (#6170) @Yohei-Shiina
+
+### 🚀 Improvement
+
+- imprv: Redirect when the anchor is #password (#6144) @Kami-jo
+
+### 🐛 Bug Fixes
+
+- fix: User registration page is not redirected after tmp login (#6197) @kaoritokashiki
+- fix: Empty trash doesn't work (#6168) @yukendev
+
+### 🧰 Maintenance
+
+- support: Ease rate limit temporary (#6191) @yuki-takei
+- support: Omit page history container and page revision comparer container (#6185) @yukendev
+
+## [v5.0.10](https://github.com/weseek/growi/compare/v5.0.9...v5.0.10) - 2022-06-27
+
+### 💎 Features
+
+- feat: Sidebar default mode settings (#6111) @yukendev
+- feat: Get GCS instance that uses Application Default Credentials for v5 (#6051) @Yohei-Shiina
+- feat: Resume rename on server boot (#5862)(#6014) @Yohei-Shiina
+- feat: Show page item control menu on empty page (#6070)(#6103) @Yohei-Shiina
+
+### 🚀 Improvement
+
+- imprv: Show page control on subnavigation at existing empty page  (#5638) @Yohei-Shiina
+- imprv: Remove toc and page authors in empty page (#5661) @Yohei-Shiina
+- imprv: SWRize apiGet /tag.search (#6062) @kaoritokashiki
+
+### 🐛 Bug Fixes
+
+- fix: Scrolling preview (#6148) @yuki-takei
+- fix: Show page history comparation modal on init (#6072) @hirokei-camel
+- fix: Ensure backword compatibility for ES6 when using max_analyzed_offset (#6121) @hakumizuki
+- fix: Set max_analyzed_offset to elasticsearch querying options (#6115) @hakumizuki
+- fix: Revision err when updating tags (#6073) @kaoritokashiki
+- fix: Support 3 types of syntax for OpenID Connect Issuer Host (#6061) @mudana-grune
+
+### 🧰 Maintenance
+
+- support: Omit comment container (#6147) @yuki-takei
+- support: Upgrade typescript to ^4.6.0 (#6082) @hakumizuki
+
 ## [v5.0.9](https://github.com/weseek/growi/compare/v5.0.8...v5.0.9) - 2022-06-13
 ## [v5.0.9](https://github.com/weseek/growi/compare/v5.0.8...v5.0.9) - 2022-06-13
 
 
 ### 🚀 Improvement
 ### 🚀 Improvement

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

+ 5 - 0
packages/app/.env.development

@@ -29,3 +29,8 @@ OGP_URI="http://ogp:8088"
 # SLACKBOT_WITHOUT_PROXY_BOT_TOKEN=''
 # SLACKBOT_WITHOUT_PROXY_BOT_TOKEN=''
 # GROWI_CLOUD_URI='http://growi.cloud'
 # GROWI_CLOUD_URI='http://growi.cloud'
 # GROWI_APP_ID_FOR_GROWI_CLOUD=012345
 # GROWI_APP_ID_FOR_GROWI_CLOUD=012345
+# AUDIT_LOG_ENABLED=false
+# ACTIVITY_EXPIRATION_SECONDS=2592000
+# AUDIT_LOG_ACTION_GROUP_SIZE=SMALL
+# AUDIT_LOG_ADDITIONAL_ACTIONS=
+# AUDIT_LOG_EXCLUDE_ACTIONS=

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

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 ------------------------------------------------
 
 
-* [`5.0.9`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.9/docker/Dockerfile)
-* [`5.0.9-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.9/docker/Dockerfile)
+* [`5.0.11`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/docker/Dockerfile)
+* [`5.0.11-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/docker/Dockerfile)
 * [`4.5.22`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.22/docker/Dockerfile)
 * [`4.5.22`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.22/docker/Dockerfile)
 * [`4.5.22-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.22/docker/Dockerfile)
 * [`4.5.22-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.22/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)

+ 8 - 7
packages/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "5.0.10-RC.0",
+  "version": "5.0.12-RC.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
@@ -64,11 +64,11 @@
     "@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": "^5.0.10-RC.0",
-    "@growi/plugin-attachment-refs": "^5.0.10-RC.0",
-    "@growi/plugin-lsx": "^5.0.10-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.10-RC.0",
-    "@growi/slack": "^5.0.10-RC.0",
+    "@growi/codemirror-textlint": "^5.0.12-RC.0",
+    "@growi/plugin-attachment-refs": "^5.0.12-RC.0",
+    "@growi/plugin-lsx": "^5.0.12-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.12-RC.0",
+    "@growi/slack": "^5.0.12-RC.0",
     "@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",
@@ -142,6 +142,7 @@
     "passport-twitter": "^1.0.4",
     "passport-twitter": "^1.0.4",
     "prom-client": "^13.0.0",
     "prom-client": "^13.0.0",
     "react-card-flip": "^1.0.10",
     "react-card-flip": "^1.0.10",
+    "react-datepicker": "^4.7.0",
     "react-dnd": "^14.0.5",
     "react-dnd": "^14.0.5",
     "react-dnd-html5-backend": "^14.1.0",
     "react-dnd-html5-backend": "^14.1.0",
     "react-image-crop": "^8.3.0",
     "react-image-crop": "^8.3.0",
@@ -169,7 +170,7 @@
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.0.10-RC.0",
+    "@growi/ui": "^5.0.12-RC.0",
     "@handsontable/react": "=2.1.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
     "@types/express": "^4.17.11",

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

@@ -521,5 +521,21 @@
       "force_update_parents_label": "Forcibly add missing users",
       "force_update_parents_label": "Forcibly add missing users",
       "force_update_parents_description": "Enable this option to force the addition of missing users to the ancestor groups if they exist after changing a parent group."
       "force_update_parents_description": "Enable this option to force the addition of missing users to the ancestor groups if they exist after changing a parent group."
     }
     }
+  },
+  "audit_log_management": {
+    "username": "Username",
+    "date": "Date",
+    "action": "Action",
+    "ip": "IP Address",
+    "url": "URL",
+    "settings": "Settings",
+    "return": "Return",
+    "activity_expiration_date": "Audit Log expiration date",
+    "activity_expiration_date_explain": "Created Audit Log are automatically deleted after the number of seconds set in the environment variable from the creation time",
+    "fixed_by_env_var": "This is fixed by the env var <code>{{key}}={{value}}</code>.",
+    "available_action_list": "Search / View All Available Actions",
+    "available_action_list_explain": "List of actions that can be search / view in the Audit Log",
+    "action_list": "Action List",
+    "disable_mode_explain": "Audit log is currently disabled. To enable it, set the environment variable <code>AUDIT_LOG_ENABLED</code> to true."
   }
   }
 }
 }

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

@@ -126,6 +126,8 @@
   "UserGroup": "UserGroup",
   "UserGroup": "UserGroup",
   "ChildUserGroup": "ChildUserGroup",
   "ChildUserGroup": "ChildUserGroup",
   "UserGroup Management": "UserGroup Management",
   "UserGroup Management": "UserGroup Management",
+  "AuditLog": "Audit Log",
+  "AuditLog Settings": "Audit Log Settings",
   "Full Text Search Management": "Full Text Search Management",
   "Full Text Search Management": "Full Text Search Management",
   "Import Data": "Import Data",
   "Import Data": "Import Data",
   "Export Archive Data": "Export Archive Data",
   "Export Archive Data": "Export Archive Data",

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

@@ -520,5 +520,21 @@
       "force_update_parents_label": "強制的に足りないユーザーを追加する",
       "force_update_parents_label": "強制的に足りないユーザーを追加する",
       "force_update_parents_description": "このオプションを有効化すると、親グループ変更後に祖先グループに足りないユーザーが存在した場合にそれらのユーザーを強制的に追加することができます"
       "force_update_parents_description": "このオプションを有効化すると、親グループ変更後に祖先グループに足りないユーザーが存在した場合にそれらのユーザーを強制的に追加することができます"
     }
     }
+  },
+  "audit_log_management": {
+    "username": "ユーザー名",
+    "date": "日付",
+    "action": "アクション",
+    "ip": "IPアドレス",
+    "url": "URL",
+    "settings": "設定",
+    "return": "戻る",
+    "activity_expiration_date": "監査ログの有効期限",
+    "activity_expiration_date_explain": "作成された監査ログは、作成時間から環境変数に設定した秒数後に自動的に削除されます",
+    "fixed_by_env_var": "環境変数により固定されています <code>{{key}}={{value}}</code>.",
+    "available_action_list": "検索 / 表示 可能なアクション一覧",
+    "available_action_list_explain": "監査ログで 検索 / 表示 可能なアクション一覧です",
+    "action_list": "アクション一覧",
+    "disable_mode_explain": "現在、監査ログは無効になっています。有効にする場合は環境変数 <code>AUDIT_LOG_ENABLED</code> を true に設定してください。"
   }
   }
 }
 }

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

@@ -126,6 +126,8 @@
   "UserGroup": "グループ",
   "UserGroup": "グループ",
   "ChildUserGroup": "子グループ",
   "ChildUserGroup": "子グループ",
   "UserGroup Management": "グループ管理",
   "UserGroup Management": "グループ管理",
+  "AuditLog": "監査ログ",
+  "AuditLog Settings": "監査ログ設定",
   "Full Text Search Management": "全文検索管理",
   "Full Text Search Management": "全文検索管理",
   "Import Data": "データインポート",
   "Import Data": "データインポート",
   "Export Archive Data": "データアーカイブ",
   "Export Archive Data": "データアーカイブ",

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

@@ -530,5 +530,21 @@
       "force_update_parents_label": "强行添加失踪的用户",
       "force_update_parents_label": "强行添加失踪的用户",
       "force_update_parents_description": "激活这个选项,如果在父组改变后,在祖先组中有缺失的用户,可以强制添加这些用户"
       "force_update_parents_description": "激活这个选项,如果在父组改变后,在祖先组中有缺失的用户,可以强制添加这些用户"
     }
     }
+  },
+  "audit_log_management": {
+    "username": "帐号",
+    "date": "日期",
+    "action": "行动",
+    "ip": "IP地址",
+    "url": "URL",
+    "settings": "设置",
+    "return": "返回",
+    "activity_expiration_date": "审计日志的到期日",
+    "activity_expiration_date_explain": "创建的审计日志会在环境变量中设置的从创建时间算起的秒数后自动删除",
+    "fixed_by_env_var": "这是由env var 修复的 <code>{{key}}={{value}}</code>.",
+    "available_action_list": "搜索/查看 所有可用的行动",
+    "available_action_list_explain": "可以在审计日志中 搜索/查看 的行动列表",
+    "action_list": "行动清单",
+    "disable_mode_explain": "审计日志当前已禁用。 要启用它,请将环境变量 <code>AUDIT_LOG_ENABLED</code> 设置为 true。"
   }
   }
 }
 }

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

@@ -134,6 +134,8 @@
   "UserGroup": "用户组",
   "UserGroup": "用户组",
   "ChildUserGroup": "儿童用户组",
   "ChildUserGroup": "儿童用户组",
 	"UserGroup Management": "用户组管理",
 	"UserGroup Management": "用户组管理",
+  "AuditLog": "审计日志",
+  "AuditLog Settings": "审计日志设置",
 	"Full Text Search Management": "全文搜索管理",
 	"Full Text Search Management": "全文搜索管理",
 	"Import Data": "导入数据",
 	"Import Data": "导入数据",
 	"Export Archive Data": "导出主题数据",
 	"Export Archive Data": "导出主题数据",

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

@@ -31,6 +31,7 @@ import { swrGlobalConfiguration } from '~/utils/swr-utils';
 
 
 import AdminHome from '../components/Admin/AdminHome/AdminHome';
 import AdminHome from '../components/Admin/AdminHome/AdminHome';
 import AppSettingsPage from '../components/Admin/App/AppSettingsPage';
 import AppSettingsPage from '../components/Admin/App/AppSettingsPage';
+import { AuditLogManagement } from '../components/Admin/AuditLogManagement';
 import AdminNavigation from '../components/Admin/Common/AdminNavigation';
 import AdminNavigation from '../components/Admin/Common/AdminNavigation';
 import Customize from '../components/Admin/Customize/Customize';
 import Customize from '../components/Admin/Customize/Customize';
 import ExportArchiveDataPage from '../components/Admin/ExportArchiveDataPage';
 import ExportArchiveDataPage from '../components/Admin/ExportArchiveDataPage';
@@ -107,6 +108,7 @@ Object.assign(componentMappings, {
   'admin-user-group-detail': <UserGroupDetailPage />,
   'admin-user-group-detail': <UserGroupDetailPage />,
   'admin-full-text-search-management': <FullTextSearchManagement />,
   'admin-full-text-search-management': <FullTextSearchManagement />,
   'admin-user-group-page': <UserGroupPage />,
   'admin-user-group-page': <UserGroupPage />,
+  'admin-audit-log': <AuditLogManagement />,
   'admin-navigation': <AdminNavigation />,
   'admin-navigation': <AdminNavigation />,
 });
 });
 
 

+ 2 - 21
packages/app/src/client/app.jsx

@@ -7,13 +7,9 @@ import { I18nextProvider } from 'react-i18next';
 import { SWRConfig } from 'swr';
 import { SWRConfig } from 'swr';
 import { Provider } from 'unstated';
 import { Provider } from 'unstated';
 
 
-import CommentContainer from '~/client/services/CommentContainer';
 import ContextExtractor from '~/client/services/ContextExtractor';
 import ContextExtractor from '~/client/services/ContextExtractor';
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
-import PageHistoryContainer from '~/client/services/PageHistoryContainer';
-import PersonalContainer from '~/client/services/PersonalContainer';
-import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
 import IdenticalPathPage from '~/components/IdenticalPathPage';
 import IdenticalPathPage from '~/components/IdenticalPathPage';
 import PrivateLegacyPages from '~/components/PrivateLegacyPages';
 import PrivateLegacyPages from '~/components/PrivateLegacyPages';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -49,8 +45,6 @@ import TagPage from '../components/TagPage';
 import TrashPageList from '../components/TrashPageList';
 import TrashPageList from '../components/TrashPageList';
 
 
 import { appContainer, componentMappings } from './base';
 import { appContainer, componentMappings } from './base';
-import { toastError } from './util/apiNotification';
-
 
 
 const logger = loggerFactory('growi:cli:app');
 const logger = loggerFactory('growi:cli:app');
 
 
@@ -61,14 +55,9 @@ const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 
 
 // create unstated container instance
 // create unstated container instance
 const pageContainer = new PageContainer(appContainer);
 const pageContainer = new PageContainer(appContainer);
-const pageHistoryContainer = new PageHistoryContainer(appContainer, pageContainer);
-const revisionComparerContainer = new RevisionComparerContainer(appContainer, pageContainer);
-const commentContainer = new CommentContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer);
-const personalContainer = new PersonalContainer(appContainer);
 const injectableContainers = [
 const injectableContainers = [
-  appContainer, socketIoContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
-  commentContainer, editorContainer, personalContainer,
+  appContainer, socketIoContainer, pageContainer, editorContainer,
 ];
 ];
 
 
 logger.info('unstated containers have been initialized');
 logger.info('unstated containers have been initialized');
@@ -104,8 +93,7 @@ Object.assign(componentMappings, {
 
 
   'page-timeline': <PageTimeline />,
   'page-timeline': <PageTimeline />,
 
 
-  'personal-setting': <PersonalSettings crowi={personalContainer} />,
-
+  'personal-setting': <PersonalSettings />,
   'my-drafts': <MyDraftList />,
   'my-drafts': <MyDraftList />,
 
 
   'grw-fab-container': <Fab />,
   'grw-fab-container': <Fab />,
@@ -128,18 +116,11 @@ if (pageContainer.state.pageId != null) {
 
 
     'recent-created-icon': <RecentlyCreatedIcon />,
     'recent-created-icon': <RecentlyCreatedIcon />,
   });
   });
-
   if (!pageContainer.state.isEmpty) {
   if (!pageContainer.state.isEmpty) {
     Object.assign(componentMappings, {
     Object.assign(componentMappings, {
       'fix-page-grant-alert': <FixPageGrantAlert />,
       'fix-page-grant-alert': <FixPageGrantAlert />,
     });
     });
   }
   }
-
-  // show the Page accessory modal when query of "compare" is requested
-  if (revisionComparerContainer.getRevisionIDsToCompareAsParam().length > 0) {
-    toastError('Sorry, opening PageAccessoriesModal is not implemented yet in v5.');
-  //   pageAccessoriesContainer.openPageAccessoriesModal('pageHistory');
-  }
 }
 }
 if (pageContainer.state.creator != null) {
 if (pageContainer.state.creator != null) {
   Object.assign(componentMappings, {
   Object.assign(componentMappings, {

+ 3 - 0
packages/app/src/client/base.jsx

@@ -23,6 +23,8 @@ import PageDuplicateModal from '../components/PageDuplicateModal';
 import PagePresentationModal from '../components/PagePresentationModal';
 import PagePresentationModal from '../components/PagePresentationModal';
 import PageRenameModal from '../components/PageRenameModal';
 import PageRenameModal from '../components/PageRenameModal';
 
 
+import ShowPageAccessoriesModal from './services/ShowPageAccessoriesModal';
+
 const logger = loggerFactory('growi:cli:app');
 const logger = loggerFactory('growi:cli:app');
 
 
 if (!window) {
 if (!window) {
@@ -69,6 +71,7 @@ const componentMappings = {
   'system-version': <SystemVersion />,
   'system-version': <SystemVersion />,
 
 
 
 
+  'show-page-accessories-modal': <ShowPageAccessoriesModal />,
 };
 };
 
 
 export { appContainer, componentMappings };
 export { appContainer, componentMappings };

+ 0 - 167
packages/app/src/client/services/CommentContainer.js

@@ -1,167 +0,0 @@
-import { Container } from 'unstated';
-
-import loggerFactory from '~/utils/logger';
-
-import { apiGet, apiPost, apiPostForm } from '../util/apiv1-client';
-import { apiv3Put } from '../util/apiv3-client';
-
-const logger = loggerFactory('growi:services:CommentContainer');
-
-/**
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @extends {Container} unstated Container
- */
-export default class CommentContainer extends Container {
-
-  constructor(appContainer) {
-    super();
-
-    this.appContainer = appContainer;
-    this.appContainer.registerContainer(this);
-
-    const mainContent = document.querySelector('#content-main');
-
-    if (mainContent == null) {
-      logger.debug('#content-main element is not exists');
-      return;
-    }
-
-    this.state = {
-      comments: [],
-    };
-
-    this.retrieveComments = this.retrieveComments.bind(this);
-    this.checkAndUpdateImageOfCommentAuthers = this.checkAndUpdateImageOfCommentAuthers.bind(this);
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'CommentContainer';
-  }
-
-  getPageContainer() {
-    return this.appContainer.getContainer('PageContainer');
-  }
-
-  findAndSplice(comment) {
-    const comments = this.state.comments;
-
-    const index = comments.indexOf(comment);
-    if (index < 0) {
-      return;
-    }
-    comments.splice(index, 1);
-
-    this.setState({ comments });
-  }
-
-  /**
-   * Load data of comments and store them in state
-   */
-  async retrieveComments() {
-    const { pageId } = this.getPageContainer().state;
-
-    // get data (desc order array)
-    const res = await apiGet('/comments.get', { page_id: pageId });
-    if (res.ok) {
-      const comments = res.comments;
-      this.setState({ comments });
-
-      this.checkAndUpdateImageOfCommentAuthers(comments);
-    }
-  }
-
-  async checkAndUpdateImageOfCommentAuthers(comments) {
-    const noImageCacheUserIds = comments.filter((comment) => {
-      const { creator } = comment;
-      return creator != null && creator.imageUrlCached == null;
-    }).map((comment) => {
-      return comment.creator._id;
-    });
-
-    if (noImageCacheUserIds.length === 0) {
-      return;
-    }
-
-    try {
-      await apiv3Put('/users/update.imageUrlCache', { userIds: noImageCacheUserIds });
-    }
-    catch (err) {
-      // Error alert doesn't apear, because user don't need to notice this error.
-      logger.error(err);
-    }
-  }
-
-  /**
-   * Load data of comments and rerender <PageComments />
-   */
-  postComment(comment, isMarkdown, replyTo, isSlackEnabled, slackChannels) {
-    const { pageId, revisionId } = this.getPageContainer().state;
-
-    return apiPost('/comments.add', {
-      commentForm: {
-        comment,
-        page_id: pageId,
-        revision_id: revisionId,
-        is_markdown: isMarkdown,
-        replyTo,
-      },
-      slackNotificationForm: {
-        isSlackEnabled,
-        slackChannels,
-      },
-    })
-      .then((res) => {
-        if (res.ok) {
-          return this.retrieveComments();
-        }
-      });
-  }
-
-  /**
-   * Load data of comments and rerender <PageComments />
-   */
-  putComment(comment, isMarkdown, commentId, author) {
-    const { pageId, revisionId } = this.getPageContainer().state;
-
-    return apiPost('/comments.update', {
-      commentForm: {
-        comment,
-        is_markdown: isMarkdown,
-        revision_id: revisionId,
-        comment_id: commentId,
-      },
-    })
-      .then((res) => {
-        if (res.ok) {
-          return this.retrieveComments();
-        }
-      });
-  }
-
-  deleteComment(comment) {
-    return apiPost('/comments.remove', { comment_id: comment._id })
-      .then((res) => {
-        if (res.ok) {
-          this.findAndSplice(comment);
-        }
-      });
-  }
-
-  uploadAttachment(file) {
-    const { pageId, pagePath } = this.getPageContainer().state;
-
-    const endpoint = '/attachments.add';
-    const formData = new FormData();
-    formData.append('file', file);
-    formData.append('path', pagePath);
-    formData.append('page_id', pageId);
-
-    return apiPostForm(endpoint, formData);
-  }
-
-}

+ 7 - 7
packages/app/src/client/services/ContextExtractor.tsx

@@ -13,12 +13,13 @@ import { useSetupGlobalSocket, useSetupGlobalAdminSocket } from '~/stores/websoc
 import {
 import {
   useSiteUrl,
   useSiteUrl,
   useCurrentCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd,
   useCurrentCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd,
-  useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
+  useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
   useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
-  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
-  useDefaultIndentSize, useIsIndentSizeForced, useCsrfToken, useIsEmptyPage, useEmptyPageId, useGrowiVersion,
+  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader,
+  useDefaultIndentSize, useIsIndentSizeForced, useCsrfToken, useIsEmptyPage, useEmptyPageId, useGrowiVersion, useAuditLogEnabled,
+  useActivityExpirationSeconds, useAuditLogAvailableActions,
 } from '../../stores/context';
 } from '../../stores/context';
 import { useRendererSettings } from '~/stores/renderer';
 import { useRendererSettings } from '~/stores/renderer';
 
 
@@ -75,7 +76,6 @@ const ContextExtractorOnce: FC = () => {
   const isIdenticalPath = JSON.parse(mainContent?.getAttribute('data-identical-path') || jsonNull) ?? false;
   const isIdenticalPath = JSON.parse(mainContent?.getAttribute('data-identical-path') || jsonNull) ?? false;
   const isUserPage = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull) != null;
   const isUserPage = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull) != null;
   const isTrashPage = _isTrashPage(path);
   const isTrashPage = _isTrashPage(path);
-  const isDeleted = JSON.parse(mainContent?.getAttribute('data-page-is-deleted') || jsonNull) ?? false;
   const isNotCreatable = JSON.parse(mainContent?.getAttribute('data-page-is-not-creatable') || jsonNull) ?? false;
   const isNotCreatable = JSON.parse(mainContent?.getAttribute('data-page-is-not-creatable') || jsonNull) ?? false;
   const isForbidden = forbiddenContent != null;
   const isForbidden = forbiddenContent != null;
   const pageUser = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
   const pageUser = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
@@ -93,7 +93,6 @@ const ContextExtractorOnce: FC = () => {
   const revisionAuthor = JSON.parse(mainContent?.getAttribute('data-page-revision-author') || jsonNull);
   const revisionAuthor = JSON.parse(mainContent?.getAttribute('data-page-revision-author') || jsonNull);
   const targetAndAncestors = JSON.parse(document.getElementById('growi-pagetree-target-and-ancestors')?.textContent || jsonNull);
   const targetAndAncestors = JSON.parse(document.getElementById('growi-pagetree-target-and-ancestors')?.textContent || jsonNull);
   const notFoundTargetPathOrId = JSON.parse(notFoundContentForPt?.getAttribute('data-not-found-target-path-or-id') || jsonNull);
   const notFoundTargetPathOrId = JSON.parse(notFoundContentForPt?.getAttribute('data-not-found-target-path-or-id') || jsonNull);
-  const isNotFoundPermalink = JSON.parse(notFoundContext?.getAttribute('data-is-not-found-permalink') || jsonNull);
   const isSearchPage = document.getElementById('search-page') != null;
   const isSearchPage = document.getElementById('search-page') != null;
   const isEmptyPage = JSON.parse(mainContent?.getAttribute('data-page-is-empty') || jsonNull) ?? false;
   const isEmptyPage = JSON.parse(mainContent?.getAttribute('data-page-is-empty') || jsonNull) ?? false;
 
 
@@ -124,6 +123,9 @@ const ContextExtractorOnce: FC = () => {
   useIsEnabledAttachTitleHeader(configByContextHydrate.isEnabledAttachTitleHeader);
   useIsEnabledAttachTitleHeader(configByContextHydrate.isEnabledAttachTitleHeader);
   useIsIndentSizeForced(configByContextHydrate.isIndentSizeForced);
   useIsIndentSizeForced(configByContextHydrate.isIndentSizeForced);
   useDefaultIndentSize(configByContextHydrate.adminPreferredIndentSize);
   useDefaultIndentSize(configByContextHydrate.adminPreferredIndentSize);
+  useAuditLogEnabled(configByContextHydrate.auditLogEnabled);
+  useActivityExpirationSeconds(configByContextHydrate.activityExpirationSeconds);
+  useAuditLogAvailableActions(configByContextHydrate.auditLogAvailableActions);
   useGrowiVersion(configByContextHydrate.crowi.version);
   useGrowiVersion(configByContextHydrate.crowi.version);
   useRendererSettings({
   useRendererSettings({
     isEnabledLinebreaks: configByContextHydrate.isEnabledLinebreaks,
     isEnabledLinebreaks: configByContextHydrate.isEnabledLinebreaks,
@@ -139,7 +141,6 @@ const ContextExtractorOnce: FC = () => {
   useHasChildren(hasChildren);
   useHasChildren(hasChildren);
   useHasDraftOnHackmd(hasDraftOnHackmd);
   useHasDraftOnHackmd(hasDraftOnHackmd);
   useIsIdenticalPath(isIdenticalPath);
   useIsIdenticalPath(isIdenticalPath);
-  useIsDeleted(isDeleted);
   useIsNotCreatable(isNotCreatable);
   useIsNotCreatable(isNotCreatable);
   useIsForbidden(isForbidden);
   useIsForbidden(isForbidden);
   useIsTrashPage(isTrashPage);
   useIsTrashPage(isTrashPage);
@@ -161,7 +162,6 @@ const ContextExtractorOnce: FC = () => {
   useRevisionAuthor(revisionAuthor);
   useRevisionAuthor(revisionAuthor);
   useTargetAndAncestors(targetAndAncestors);
   useTargetAndAncestors(targetAndAncestors);
   useNotFoundTargetPathOrId(notFoundTargetPathOrId);
   useNotFoundTargetPathOrId(notFoundTargetPathOrId);
-  useIsNotFoundPermalink(isNotFoundPermalink);
   useIsSearchPage(isSearchPage);
   useIsSearchPage(isSearchPage);
   useIsEmptyPage(isEmptyPage);
   useIsEmptyPage(isEmptyPage);
   useHasParent(hasParent);
   useHasParent(hasParent);

+ 0 - 1
packages/app/src/client/services/PageContainer.js

@@ -61,7 +61,6 @@ export default class PageContainer extends Container {
 
 
       isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null,
       isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null,
       isTrashPage: isTrashPage(path),
       isTrashPage: isTrashPage(path),
-      isDeleted: JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
       isNotCreatable: JSON.parse(mainContent.getAttribute('data-page-is-not-creatable')),
       isNotCreatable: JSON.parse(mainContent.getAttribute('data-page-is-not-creatable')),
       isPageExist: mainContent.getAttribute('data-page-id') != null,
       isPageExist: mainContent.getAttribute('data-page-id') != null,
 
 

+ 0 - 172
packages/app/src/client/services/PageHistoryContainer.js

@@ -1,172 +0,0 @@
-import { Container } from 'unstated';
-
-import loggerFactory from '~/utils/logger';
-
-import { toastError } from '../util/apiNotification';
-import { apiv3Get } from '../util/apiv3-client';
-
-const logger = loggerFactory('growi:PageHistoryContainer');
-
-/**
- * Service container for personal settings page (PageHistory.jsx)
- * @extends {Container} unstated Container
- */
-export default class PageHistoryContainer extends Container {
-
-  constructor(appContainer, pageContainer) {
-    super();
-
-    this.appContainer = appContainer;
-    this.pageContainer = pageContainer;
-    this.dummyRevisions = 0;
-
-    this.state = {
-      errorMessage: null,
-
-      // set dummy rivisions for using suspense
-      revisions: this.dummyRevisions,
-      latestRevision: this.dummyRevisions,
-      oldestRevision: this.dummyRevisions,
-      diffOpened: {},
-
-      totalPages: 0,
-      activePage: 1,
-      pagingLimit: 10,
-    };
-
-    this.retrieveRevisions = this.retrieveRevisions.bind(this);
-    this.getPreviousRevision = this.getPreviousRevision.bind(this);
-    this.fetchPageRevisionBody = this.fetchPageRevisionBody.bind(this);
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'PageHistoryContainer';
-  }
-
-  /**
-   * syncRevisions of selectedPage
-   * @param {number} selectedPage
-   */
-  async retrieveRevisions(selectedPage) {
-    const { pageId, shareLinkId } = this.pageContainer.state;
-    const { pagingLimit } = this.state;
-    const page = selectedPage;
-    const pagingLimitForApiParam = pagingLimit + 1;
-
-    if (!pageId) {
-      return;
-    }
-
-    // Get one more for the bottom display
-    const res = await apiv3Get('/revisions/list', {
-      pageId, shareLinkId, page, limit: pagingLimitForApiParam,
-    });
-    const rev = res.data.docs;
-    // set Pagination state
-    this.setState({
-      activePage: selectedPage,
-      totalPages: res.data.totalDocs,
-      pagingLimit,
-    });
-
-    const diffOpened = {};
-
-    let lastId = rev.length - 1;
-
-    // If the number of rev count is the same, the last rev is for diff display, so exclude it.
-    if (rev.length > pagingLimit) {
-      lastId = rev.length - 2;
-    }
-
-    res.data.docs.forEach((revision, i) => {
-      const user = revision.author;
-      if (user) {
-        rev[i].author = user;
-      }
-
-      if (i === 0 || i === lastId) {
-        diffOpened[revision._id] = true;
-      }
-      else {
-        diffOpened[revision._id] = false;
-      }
-    });
-
-    this.setState({ revisions: rev });
-    this.setState({ diffOpened });
-
-    if (selectedPage === 1) {
-      this.setState({ latestRevision: rev[0] });
-    }
-
-    if (selectedPage === res.data.totalPages) {
-      this.setState({ oldestRevision: rev[lastId] });
-    }
-
-    // load 0, and last default
-    if (rev[0]) {
-      this.fetchPageRevisionBody(rev[0]);
-    }
-    if (rev[1]) {
-      this.fetchPageRevisionBody(rev[1]);
-    }
-    if (lastId !== 0 && lastId !== 1 && rev[lastId]) {
-      this.fetchPageRevisionBody(rev[lastId]);
-    }
-
-    return;
-  }
-
-  getPreviousRevision(currentRevision) {
-    let cursor = null;
-    for (const revision of this.state.revisions) {
-      // comparing ObjectId
-      // eslint-disable-next-line eqeqeq
-      if (cursor && cursor._id == currentRevision._id) {
-        cursor = revision;
-        break;
-      }
-
-      cursor = revision;
-    }
-
-    return cursor;
-  }
-
-  /**
-   * fetch page revision body by revision in argument
-   * @param {object} revision
-   */
-  async fetchPageRevisionBody(revision) {
-    const { pageId, shareLinkId } = this.pageContainer.state;
-
-    if (revision.body) {
-      return;
-    }
-
-    try {
-      const res = await apiv3Get(`/revisions/${revision._id}`, { pageId, shareLinkId });
-      this.setState({
-        revisions: this.state.revisions.map((rev) => {
-          // comparing ObjectId
-          // eslint-disable-next-line eqeqeq
-          if (rev._id == res.data.revision._id) {
-            return res.data.revision;
-          }
-
-          return rev;
-        }),
-      });
-    }
-    catch (err) {
-      toastError(err);
-      this.setState({ errorMessage: err.message });
-      logger.error(err);
-    }
-  }
-
-
-}

+ 0 - 186
packages/app/src/client/services/PersonalContainer.js

@@ -1,186 +0,0 @@
-import { Container } from 'unstated';
-
-import loggerFactory from '~/utils/logger';
-
-import { apiPost } from '../util/apiv1-client';
-import { apiv3Get, apiv3Put } from '../util/apiv3-client';
-
-// eslint-disable-next-line no-unused-vars
-const logger = loggerFactory('growi:services:PersonalContainer');
-
-/**
- * Service container for personal settings page (PersonalSettings.jsx)
- * @extends {Container} unstated Container
- */
-export default class PersonalContainer extends Container {
-
-  constructor(appContainer) {
-    super();
-
-    this.appContainer = appContainer;
-
-    this.state = {
-      retrieveError: null,
-      name: '',
-      email: '',
-      registrationWhiteList: this.appContainer.getConfig().registrationWhiteList,
-      isEmailPublished: false,
-      lang: 'en_US',
-      isGravatarEnabled: false,
-      externalAccounts: [],
-      apiToken: '',
-      slackMemberId: '',
-    };
-
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'PersonalContainer';
-  }
-
-  /**
-   * retrieve personal data
-   */
-  async retrievePersonalData() {
-    try {
-      const response = await apiv3Get('/personal-setting/');
-      const { currentUser } = response.data;
-      this.setState({
-        name: currentUser.name,
-        email: currentUser.email,
-        isEmailPublished: currentUser.isEmailPublished,
-        lang: currentUser.lang,
-        isGravatarEnabled: currentUser.isGravatarEnabled,
-        apiToken: currentUser.apiToken,
-        slackMemberId: currentUser.slackMemberId,
-      });
-    }
-    catch (err) {
-      this.setState({ retrieveError: err });
-      logger.error(err);
-      throw new Error('Failed to fetch personal data');
-    }
-  }
-
-  /**
-   * retrieve external accounts that linked me
-   */
-  async retrieveExternalAccounts() {
-    try {
-      const response = await apiv3Get('/personal-setting/external-accounts');
-      const { externalAccounts } = response.data;
-
-      this.setState({ externalAccounts });
-    }
-    catch (err) {
-      this.setState({ retrieveError: err });
-      logger.error(err);
-      throw new Error('Failed to fetch external accounts');
-    }
-  }
-
-  /**
-   * Change name
-   */
-  changeName(inputValue) {
-    this.setState({ name: inputValue });
-  }
-
-  /**
-   * Change email
-   */
-  changeEmail(inputValue) {
-    this.setState({ email: inputValue });
-  }
-
-  /**
-   * Change Slack Member ID
-   */
-  changeSlackMemberId(inputValue) {
-    this.setState({ slackMemberId: inputValue });
-  }
-
-  /**
-   * Change isEmailPublished
-   */
-  changeIsEmailPublished(boolean) {
-    this.setState({ isEmailPublished: boolean });
-  }
-
-  /**
-   * Change lang
-   */
-  changeLang(lang) {
-    this.setState({ lang });
-  }
-
-  /**
-   * Change isGravatarEnabled
-   */
-  changeIsGravatarEnabled(boolean) {
-    this.setState({ isGravatarEnabled: boolean });
-  }
-
-  /**
-   * Update basic info
-   * @memberOf PersonalContainer
-   * @return {Array} basic info
-   */
-  async updateBasicInfo() {
-    try {
-      const response = await apiv3Put('/personal-setting/', {
-        name: this.state.name,
-        email: this.state.email,
-        isEmailPublished: this.state.isEmailPublished,
-        lang: this.state.lang,
-        slackMemberId: this.state.slackMemberId,
-      });
-      const { updatedUser } = response.data;
-
-      this.setState({
-        name: updatedUser.name,
-        email: updatedUser.email,
-        isEmailPublished: updatedUser.isEmailPublished,
-        lang: updatedUser.lang,
-        slackMemberId: updatedUser.slackMemberId,
-      });
-    }
-    catch (err) {
-      this.setState({ retrieveError: err });
-      logger.error(err);
-      throw new Error('Failed to update personal data');
-    }
-  }
-
-  /**
-   * Associate LDAP account
-   */
-  async associateLdapAccount(account) {
-    try {
-      await apiv3Put('/personal-setting/associate-ldap', account);
-    }
-    catch (err) {
-      this.setState({ retrieveError: err });
-      logger.error(err);
-      throw new Error('Failed to associate ldap account');
-    }
-  }
-
-  /**
-   * Disassociate LDAP account
-   */
-  async disassociateLdapAccount(account) {
-    try {
-      await apiv3Put('/personal-setting/disassociate-ldap', account);
-    }
-    catch (err) {
-      this.setState({ retrieveError: err });
-      logger.error(err);
-      throw new Error('Failed to disassociate ldap account');
-    }
-  }
-
-}

+ 0 - 113
packages/app/src/client/services/RevisionComparerContainer.js

@@ -1,113 +0,0 @@
-import { Container } from 'unstated';
-
-import loggerFactory from '~/utils/logger';
-
-import { toastError } from '../util/apiNotification';
-import { apiv3Get } from '../util/apiv3-client';
-
-const logger = loggerFactory('growi:PageHistoryContainer');
-
-/**
- * Service container for personal settings page (RevisionCompare.jsx)
- * @extends {Container} unstated Container
- */
-export default class RevisionComparerContainer extends Container {
-
-  constructor(appContainer, pageContainer) {
-    super();
-
-    this.appContainer = appContainer;
-    this.pageContainer = pageContainer;
-
-    this.state = {
-      errMessage: null,
-
-      sourceRevision: null,
-      targetRevision: null,
-      latestRevision: null,
-    };
-
-    this.initRevisions = this.initRevisions.bind(this);
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'RevisionComparerContainer';
-  }
-
-  /**
-   * Initialize the revisions
-   */
-  async initRevisions() {
-    const latestRevision = await this.fetchLatestRevision();
-
-    const [sourceRevisionId, targetRevisionId] = this.getRevisionIDsToCompareAsParam();
-    const sourceRevision = sourceRevisionId ? await this.fetchRevision(sourceRevisionId) : latestRevision;
-    const targetRevision = targetRevisionId ? await this.fetchRevision(targetRevisionId) : latestRevision;
-    const compareWithLatest = targetRevisionId ? false : this.state.compareWithLatest;
-
-    this.setState({
-      sourceRevision, targetRevision, latestRevision, compareWithLatest,
-    });
-  }
-
-  /**
-   * Get the IDs of the comparison source and target from "window.location" as an array
-   */
-  getRevisionIDsToCompareAsParam() {
-    const searchParams = {};
-    for (const param of window.location.search?.substr(1)?.split('&')) {
-      const [k, v] = param.split('=');
-      searchParams[k] = v;
-    }
-    if (!searchParams.compare) {
-      return [];
-    }
-
-    return searchParams.compare.split('...') || [];
-  }
-
-  /**
-   * Fetch the latest revision
-   */
-  async fetchLatestRevision() {
-    const { pageId, shareLinkId } = this.pageContainer.state;
-
-    try {
-      const res = await apiv3Get('/revisions/list', {
-        pageId, shareLinkId, page: 1, limit: 1,
-      });
-      return res.data.docs[0];
-    }
-    catch (err) {
-      toastError(err);
-      this.setState({ errorMessage: err.message });
-      logger.error(err);
-    }
-    return null;
-  }
-
-  /**
-   * Fetch the revision of the specified ID
-   * @param {string} revision ID
-   */
-  async fetchRevision(revisionId) {
-    const { pageId, shareLinkId } = this.pageContainer.state;
-
-    try {
-      const res = await apiv3Get(`/revisions/${revisionId}`, {
-        pageId, shareLinkId,
-      });
-      return res.data.revision;
-    }
-    catch (err) {
-      toastError(err);
-      this.setState({ errorMessage: err.message });
-      logger.error(err);
-    }
-    return null;
-  }
-
-}

+ 34 - 0
packages/app/src/client/services/ShowPageAccessoriesModal.tsx

@@ -0,0 +1,34 @@
+import React, { useEffect, useState } from 'react';
+
+import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/modal';
+
+function getURLQueryParamValue(key: string) {
+// window.location.href is page URL;
+  const queryStr: URLSearchParams = new URL(window.location.href).searchParams;
+  return queryStr.get(key);
+}
+
+const queryCompareFormat = new RegExp(/([a-z0-9]){24}...([a-z0-9]){24}/);
+
+const ShowPageAccessoriesModal = (): JSX.Element => {
+  const { data: status, open: openPageAccessories } = usePageAccessoriesModal();
+  const [isArleadyMounted, setIsArleadyMounted] = useState(false);
+  useEffect(() => {
+    const pageIdParams = getURLQueryParamValue('compare');
+    if (status == null || status.isOpened === true) {
+      return;
+    }
+    if (isArleadyMounted === true) {
+      return;
+    }
+    if (pageIdParams != null) {
+      if (queryCompareFormat.test(pageIdParams)) {
+        openPageAccessories(PageAccessoriesModalContents.PageHistory);
+      }
+    }
+    setIsArleadyMounted(true);
+  }, [openPageAccessories, status, isArleadyMounted]);
+  return <></>;
+};
+
+export default ShowPageAccessoriesModal;

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

@@ -0,0 +1,47 @@
+import React, { FC } from 'react';
+
+import { format } from 'date-fns';
+import { useTranslation } from 'react-i18next';
+
+import { IActivityHasId } from '~/interfaces/activity';
+
+type Props = {
+  activityList: IActivityHasId[]
+}
+
+const formatDate = (date) => {
+  return format(new Date(date), 'yyyy/MM/dd HH:mm:ss');
+};
+
+export const ActivityTable : FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+
+  return (
+    <div className="table-responsive text-nowrap h-100">
+      <table className="table table-default table-bordered table-user-list">
+        <thead>
+          <tr>
+            <th scope="col">{t('admin:audit_log_management.username')}</th>
+            <th scope="col">{t('admin:audit_log_management.date')}</th>
+            <th scope="col">{t('admin:audit_log_management.action')}</th>
+            <th scope="col">{t('admin:audit_log_management.ip')}</th>
+            <th scope="col">{t('admin:audit_log_management.url')}</th>
+          </tr>
+        </thead>
+        <tbody>
+          {props.activityList.map((activity) => {
+            return (
+              <tr data-testid="activity-table" key={activity._id}>
+                <td>{activity.snapshot?.username}</td>
+                <td>{formatDate(activity.createdAt)}</td>
+                <td>{activity.action}</td>
+                <td>{activity.ip}</td>
+                <td>{activity.endpoint}</td>
+              </tr>
+            );
+          })}
+        </tbody>
+      </table>
+    </div>
+  );
+};

+ 26 - 0
packages/app/src/components/Admin/AuditLog/AuditLogDisableMode.tsx

@@ -0,0 +1,26 @@
+import React, { FC } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+export const AuditLogDisableMode: FC = () => {
+  const { t } = useTranslation();
+
+  return (
+    <div id="content-main" className="content-main container-lg">
+      <div className="container">
+        <div className="row justify-content-md-center">
+          <div className="col-md-6 mt-5">
+            <div className="text-center">
+              <h1><i className="icon-exclamation large"></i></h1>
+              <h1 className="text-center">{t('AuditLog')}</h1>
+              <h3
+                // eslint-disable-next-line react/no-danger
+                dangerouslySetInnerHTML={{ __html: t('admin:audit_log_management.disable_mode_explain') }}
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+};

+ 54 - 0
packages/app/src/components/Admin/AuditLog/AuditLogSettings.tsx

@@ -0,0 +1,54 @@
+import React, { FC, useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { Collapse } from 'reactstrap';
+
+import { useActivityExpirationSeconds, useAuditLogAvailableActions } from '~/stores/context';
+
+export const AuditLogSettings: FC = () => {
+  const { t } = useTranslation();
+
+  const [isExpandActionList, setIsExpandActionList] = useState(false);
+
+  const { data: activityExpirationSecondsData } = useActivityExpirationSeconds();
+  const activityExpirationSeconds = activityExpirationSecondsData != null ? activityExpirationSecondsData : 2592000;
+
+  const { data: availableActionsData } = useAuditLogAvailableActions();
+  const availableActions = availableActionsData != null ? availableActionsData : [];
+
+  return (
+    <>
+      <h4 className="mt-4">{t('admin:audit_log_management.activity_expiration_date')}</h4>
+      <p className="form-text text-muted">
+        {t('admin:audit_log_management.activity_expiration_date_explain')}
+      </p>
+      <p className="alert alert-warning col-6">
+        <i className="icon-exclamation icon-fw">
+        </i><b>FIXED</b><br />
+        <b
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{
+            __html: t('admin:audit_log_management.fixed_by_env_var',
+              { key: 'ACTIVITY_EXPIRATION_SECONDS', value: activityExpirationSeconds }),
+          }}
+        />
+      </p>
+
+      <h4 className="mt-4">{t('admin:audit_log_management.available_action_list')}</h4>
+      <p className="form-text text-muted">{t('admin:audit_log_management.available_action_list_explain')}</p>
+      <p className="mt-1">
+        <button type="button" className="btn btn-link p-0" aria-expanded="false" onClick={() => setIsExpandActionList(!isExpandActionList)}>
+          <i className={`fa fa-fw fa-arrow-right ${isExpandActionList ? 'fa-rotate-90' : ''}`}></i>
+          { t('admin:audit_log_management.action_list') }
+        </button>
+      </p>
+      <Collapse isOpen={isExpandActionList}>
+        <ul className="list-group">
+          { availableActions.map(action => (
+            <li key={action} className="list-group-item">{ action }</li>
+          )) }
+        </ul>
+      </Collapse>
+    </>
+  );
+};

+ 66 - 0
packages/app/src/components/Admin/AuditLog/DateRangePicker.tsx

@@ -0,0 +1,66 @@
+import React, {
+  FC, useRef, forwardRef, useCallback,
+} from 'react';
+
+import DatePicker from 'react-datepicker';
+import 'react-datepicker/dist/react-datepicker.css';
+
+import { useTranslation } from 'react-i18next';
+
+
+type CustomInputProps = {
+  buttonRef: React.Ref<HTMLButtonElement>
+  onClick?: () => void;
+}
+
+const CustomInput = forwardRef<HTMLButtonElement, CustomInputProps>((props: CustomInputProps) => {
+  const { t } = useTranslation();
+  return (
+    <button
+      type="button"
+      className="btn btn-outline-secondary dropdown-toggle"
+      ref={props.buttonRef}
+      onClick={props.onClick}
+    >
+      <i className="fa fa-fw fa-calendar" /> {t('admin:audit_log_management.date')}
+    </button>
+  );
+});
+
+
+type DateRangePickerProps = {
+  startDate: Date | null
+  endDate: Date | null
+  onChange: (dateList: Date[] | null[]) => void
+}
+
+export const DateRangePicker: FC<DateRangePickerProps> = (props: DateRangePickerProps) => {
+  const { startDate, endDate, onChange } = props;
+
+  const buttonRef = useRef(null);
+
+  const changeHandler = useCallback((dateList: Date[] | null[]) => {
+    if (onChange != null) {
+      const [start, end] = dateList;
+      const isSameTime = (start != null && end != null) && (start.getTime() === end.getTime());
+      if (isSameTime) {
+        onChange([null, null]);
+      }
+      else {
+        onChange(dateList);
+      }
+    }
+  }, [onChange]);
+
+  return (
+    <div className="btn-group mr-2">
+      <DatePicker
+        selectsRange
+        startDate={startDate}
+        endDate={endDate}
+        onChange={changeHandler}
+        customInput={<CustomInput buttonRef={buttonRef} />}
+      />
+    </div>
+  );
+};

+ 122 - 0
packages/app/src/components/Admin/AuditLog/SearchUsernameTypeahead.tsx

@@ -0,0 +1,122 @@
+import React, {
+  FC, Fragment, useState, useCallback,
+} from 'react';
+
+import { AsyncTypeahead, Menu, MenuItem } from 'react-bootstrap-typeahead';
+import { useTranslation } from 'react-i18next';
+
+import { useSWRxUsernames } from '~/stores/user';
+
+
+const Categories = {
+  activeUser: 'Active User',
+  inactiveUser: 'Inactive User',
+  activitySnapshotUser: 'Activity Snapshot User',
+} as const;
+
+type CategoryType = typeof Categories[keyof typeof Categories]
+
+type UserDataType = {
+  username: string
+  category: CategoryType
+}
+
+type Props = {
+  onChange: (text: string[]) => void
+}
+
+export const SearchUsernameTypeahead: FC<Props> = (props: Props) => {
+  const { onChange } = props;
+  const { t } = useTranslation();
+
+  /*
+   * State
+   */
+  const [searchKeyword, setSearchKeyword] = useState<string>('');
+
+  /*
+   * Fetch
+   */
+  const requestOptions = { isIncludeActiveUser: true, isIncludeInactiveUser: true, isIncludeActivitySnapshotUser: true };
+  const { data: usernameData, error } = useSWRxUsernames(searchKeyword, 0, 5, requestOptions);
+  const activeUsernames = usernameData?.activeUser?.usernames != null ? usernameData.activeUser.usernames : [];
+  const inactiveUsernames = usernameData?.inactiveUser?.usernames != null ? usernameData.inactiveUser.usernames : [];
+  const activitySnapshotUsernames = usernameData?.activitySnapshotUser?.usernames != null ? usernameData.activitySnapshotUser.usernames : [];
+  const isLoading = usernameData === undefined && error == null;
+
+  const allUser: UserDataType[] = [];
+  const pushToAllUser = (usernames: string[], category: CategoryType) => {
+    usernames.forEach(username => allUser.push({ username, category }));
+  };
+  pushToAllUser(activeUsernames, Categories.activeUser);
+  pushToAllUser(inactiveUsernames, Categories.inactiveUser);
+  pushToAllUser(activitySnapshotUsernames, Categories.activitySnapshotUser);
+
+  /*
+   * Functions
+   */
+  const changeHandler = useCallback((userData: UserDataType[]) => {
+    if (onChange != null) {
+      const usernames = userData.map(user => user.username);
+      onChange(usernames);
+    }
+  }, [onChange]);
+
+  const searchHandler = useCallback((text: string) => {
+    setSearchKeyword(text);
+  }, []);
+
+  const renderMenu = useCallback((allUser: UserDataType[], menuProps) => {
+    if (allUser == null || allUser.length === 0) {
+      return <></>;
+    }
+
+    let index = 0;
+    const items = Object.values(Categories).map((category) => {
+      const userData = allUser.filter(user => user.category === category);
+      return (
+        <Fragment key={category}>
+          {index !== 0 && <Menu.Divider />}
+          <Menu.Header>{category}</Menu.Header>
+          {userData.map((user) => {
+            const item = (
+              <MenuItem key={index} option={user} position={index}>
+                {user.username}
+              </MenuItem>
+            );
+            index++;
+            return item;
+          })}
+        </Fragment>
+      );
+    });
+
+    return (
+      <Menu {...menuProps}>{items}</Menu>
+    );
+  }, []);
+
+  return (
+    <div className="input-group mr-2">
+      <div className="input-group-prepend">
+        <span className="input-group-text">
+          <i className="icon-people" />
+        </span>
+      </div>
+      <AsyncTypeahead
+        id="search-username-typeahead-asynctypeahead"
+        multiple
+        delay={400}
+        minLength={0}
+        placeholder={t('admin:audit_log_management.username')}
+        caseSensitive={false}
+        isLoading={isLoading}
+        options={allUser}
+        onSearch={searchHandler}
+        onChange={changeHandler}
+        renderMenu={renderMenu}
+        labelKey={(option: UserDataType) => `${option.username}`}
+      />
+    </div>
+  );
+};

+ 123 - 0
packages/app/src/components/Admin/AuditLog/SelectActionDropdown.tsx

@@ -0,0 +1,123 @@
+import React, { FC, useMemo, useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import {
+  SupportedActionType, SupportedActionCategoryType, SupportedActionCategory,
+  PageActions, CommentActions, TagActions, ShareLinkActions, AttachmentActions, InAppNotificationActions, SearchActions, UserActions, AdminActions,
+} from '~/interfaces/activity';
+
+type Props = {
+  actionMap: Map<SupportedActionType, boolean>
+  availableActions: SupportedActionType[]
+  onChangeAction: (action: SupportedActionType) => void
+  onChangeMultipleAction: (actions: SupportedActionType[], isChecked: boolean) => void
+}
+
+export const SelectActionDropdown: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+  const {
+    actionMap, availableActions, onChangeAction, onChangeMultipleAction,
+  } = props;
+
+  const dropdownItems = useMemo<Array<{actionCategory: SupportedActionCategoryType, actions: SupportedActionType[]}>>(() => {
+    return (
+      [
+        {
+          actionCategory: SupportedActionCategory.PAGE,
+          actions: PageActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.COMMENT,
+          actions: CommentActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.TAG,
+          actions: TagActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.ATTACHMENT,
+          actions: AttachmentActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.SHARE_LINK,
+          actions: ShareLinkActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.IN_APP_NOTIFICATION,
+          actions: InAppNotificationActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.SEARCH,
+          actions: SearchActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.USER,
+          actions: UserActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.ADMIN,
+          actions: AdminActions.filter(action => availableActions.includes(action)),
+        },
+      ]
+    );
+  }, [availableActions]).filter(item => item.actions.length !== 0);
+
+  const actionCheckboxChangedHandler = useCallback((action) => {
+    if (onChangeAction != null) {
+      onChangeAction(action);
+    }
+  }, [onChangeAction]);
+
+  const multipleActionCheckboxChangedHandler = useCallback((actions, isChecked) => {
+    if (onChangeMultipleAction != null) {
+      onChangeMultipleAction(actions, isChecked);
+    }
+  }, [onChangeMultipleAction]);
+
+  return (
+    <div className="btn-group mr-2 admin-audit-log">
+      <button className="btn btn-outline-secondary dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown">
+        <i className="fa fa-fw fa-bolt" />{t('admin:audit_log_management.action')}
+      </button>
+      <ul className="dropdown-menu select-action-dropdown" aria-labelledby="dropdownMenuButton">
+        {dropdownItems.map(item => (
+          <div key={item.actionCategory}>
+            <div className="dropdown-item">
+              <div className="form-group px-2 m-0">
+                <input
+                  type="checkbox"
+                  className="form-check-input"
+                  defaultChecked
+                  onChange={(e) => { multipleActionCheckboxChangedHandler(item.actions, e.target.checked) }}
+                />
+                <label className="form-check-label">{item.actionCategory}</label>
+              </div>
+            </div>
+            {
+              item.actions.map(action => (
+                <div className="dropdown-item" key={action}>
+                  <div className="form-group px-4 m-0">
+                    <input
+                      type="checkbox"
+                      className="form-check-input"
+                      id={`checkbox${action}`}
+                      onChange={() => { actionCheckboxChangedHandler(action) }}
+                      checked={actionMap.get(action)}
+                    />
+                    <label
+                      className="form-check-label"
+                      htmlFor={`checkbox${action}`}
+                    >
+                      {action}
+                    </label>
+                  </div>
+                </div>
+              ))
+            }
+          </div>
+        ))}
+      </ul>
+    </div>
+  );
+};

+ 181 - 0
packages/app/src/components/Admin/AuditLogManagement.tsx

@@ -0,0 +1,181 @@
+import React, { FC, useState, useCallback } from 'react';
+
+import { format } from 'date-fns';
+import { useTranslation } from 'react-i18next';
+
+import { toastError } from '~/client/util/apiNotification';
+import { SupportedActionType } from '~/interfaces/activity';
+import { useSWRxActivity } from '~/stores/activity';
+import { useAuditLogEnabled, useAuditLogAvailableActions } from '~/stores/context';
+
+import PaginationWrapper from '../PaginationWrapper';
+
+import { ActivityTable } from './AuditLog/ActivityTable';
+import { AuditLogDisableMode } from './AuditLog/AuditLogDisableMode';
+import { AuditLogSettings } from './AuditLog/AuditLogSettings';
+import { DateRangePicker } from './AuditLog/DateRangePicker';
+import { SearchUsernameTypeahead } from './AuditLog/SearchUsernameTypeahead';
+import { SelectActionDropdown } from './AuditLog/SelectActionDropdown';
+
+
+const formatDate = (date: Date | null) => {
+  if (date == null) {
+    return '';
+  }
+  return format(new Date(date), 'yyyy-MM-dd');
+};
+
+const PAGING_LIMIT = 10;
+
+export const AuditLogManagement: FC = () => {
+  const { t } = useTranslation();
+
+  const { data: auditLogAvailableActionsData } = useAuditLogAvailableActions();
+  const auditLogAvailableActions = auditLogAvailableActionsData != null ? auditLogAvailableActionsData : [];
+
+  /*
+   * State
+   */
+  const [isSettingPage, setIsSettingPage] = useState<boolean>(false);
+  const [activePage, setActivePage] = useState<number>(1);
+  const offset = (activePage - 1) * PAGING_LIMIT;
+  const [startDate, setStartDate] = useState<Date | null>(null);
+  const [endDate, setEndDate] = useState<Date | null>(null);
+  const [selectedUsernames, setSelectedUsernames] = useState<string[]>([]);
+  const [actionMap, setActionMap] = useState(
+    new Map<SupportedActionType, boolean>(auditLogAvailableActions.map(action => [action, true])),
+  );
+
+  /*
+   * Fetch
+   */
+  const selectedDate = { startDate: formatDate(startDate), endDate: formatDate(endDate) };
+  const selectedActionList = Array.from(actionMap.entries()).filter(v => v[1]).map(v => v[0]);
+  const searchFilter = { actions: selectedActionList, dates: selectedDate, usernames: selectedUsernames };
+
+  const { data: activityData, mutate: mutateActivity, error } = useSWRxActivity(PAGING_LIMIT, offset, searchFilter);
+  const activityList = activityData?.docs != null ? activityData.docs : [];
+  const totalActivityNum = activityData?.totalDocs != null ? activityData.totalDocs : 0;
+  const isLoading = activityData === undefined && error == null;
+
+  if (error != null) {
+    toastError('Failed to get Audit Log');
+  }
+
+  const { data: auditLogEnabled } = useAuditLogEnabled();
+
+  /*
+   * Functions
+   */
+  const setActivePageHandler = useCallback((selectedPageNum: number) => {
+    setActivePage(selectedPageNum);
+  }, []);
+
+  const datePickerChangedHandler = useCallback((dateList: Date[] | null[]) => {
+    setActivePage(1);
+    setStartDate(dateList[0]);
+    setEndDate(dateList[1]);
+  }, []);
+
+  const actionCheckboxChangedHandler = useCallback((action: SupportedActionType) => {
+    setActivePage(1);
+    actionMap.set(action, !actionMap.get(action));
+    setActionMap(new Map(actionMap.entries()));
+  }, [actionMap, setActionMap]);
+
+  const multipleActionCheckboxChangedHandler = useCallback((actions: SupportedActionType[], isChecked) => {
+    setActivePage(1);
+    actions.forEach(action => actionMap.set(action, isChecked));
+    setActionMap(new Map(actionMap.entries()));
+  }, [actionMap, setActionMap]);
+
+  const setUsernamesHandler = useCallback((usernames: string[]) => {
+    setActivePage(1);
+    setSelectedUsernames(usernames);
+  }, []);
+
+  const reloadButtonPushedHandler = useCallback(() => {
+    setActivePage(1);
+    mutateActivity();
+  }, [mutateActivity]);
+
+  // eslint-disable-next-line max-len
+  const activityCounter = `<b>${activityList.length === 0 ? 0 : offset + 1}</b> - <b>${(PAGING_LIMIT * activePage) - (PAGING_LIMIT - activityList.length)}</b> of <b>${totalActivityNum}<b/>`;
+
+  if (!auditLogEnabled) {
+    return <AuditLogDisableMode />;
+  }
+
+  return (
+    <div data-testid="admin-auditlog">
+      <button type="button" className="btn btn-outline-secondary mb-4" onClick={() => setIsSettingPage(!isSettingPage)}>
+        {
+          isSettingPage
+            ? <><i className="fa fa-hand-o-left mr-1" />{t('admin:audit_log_management.return')}</>
+            : <><i className="fa icon-settings mr-1" />{t('admin:audit_log_management.settings')}</>
+        }
+      </button>
+
+      <h2 className="admin-setting-header mb-3">
+        <span>
+          {isSettingPage ? t('AuditLog Settings') : t('AuditLog')}
+        </span>
+      </h2>
+
+      {isSettingPage ? (
+        <AuditLogSettings />
+      ) : (
+        <>
+          <div className="form-inline mb-3">
+            <SearchUsernameTypeahead
+              onChange={setUsernamesHandler}
+            />
+
+            <DateRangePicker
+              startDate={startDate}
+              endDate={endDate}
+              onChange={datePickerChangedHandler}
+            />
+
+            <SelectActionDropdown
+              actionMap={actionMap}
+              availableActions={auditLogAvailableActions}
+              onChangeAction={actionCheckboxChangedHandler}
+              onChangeMultipleAction={multipleActionCheckboxChangedHandler}
+            />
+
+            <button type="button" className="btn ml-auto grw-btn-reload" onClick={reloadButtonPushedHandler}>
+              <i className="icon icon-reload" />
+            </button>
+          </div>
+
+          <p
+            className="ml-2"
+            // eslint-disable-next-line react/no-danger
+            dangerouslySetInnerHTML={{ __html: activityCounter }}
+          />
+
+          { isLoading
+            ? (
+              <div className="text-muted text-center mb-5">
+                <i className="fa fa-2x fa-spinner fa-pulse mr-1" />
+              </div>
+            )
+            : (
+              <ActivityTable activityList={activityList} />
+            )
+          }
+
+          <PaginationWrapper
+            activePage={activePage}
+            changePage={setActivePageHandler}
+            totalItemsCount={totalActivityNum}
+            pagingLimit={PAGING_LIMIT}
+            align="center"
+            size="sm"
+          />
+        </>
+      )}
+    </div>
+  );
+};

+ 4 - 0
packages/app/src/components/Admin/Common/AdminNavigation.jsx

@@ -37,6 +37,8 @@ const AdminNavigation = (props) => {
       case 'users':                    return <><i className="icon-fw icon-user"></i>            { t('User_Management') }</>;
       case 'users':                    return <><i className="icon-fw icon-user"></i>            { t('User_Management') }</>;
       case 'user-groups':              return <><i className="icon-fw icon-people"></i>          { t('UserGroup Management') }</>;
       case 'user-groups':              return <><i className="icon-fw icon-people"></i>          { t('UserGroup Management') }</>;
       case 'search':                   return <><i className="icon-fw icon-magnifier"></i>       { t('Full Text Search Management') }</>;
       case 'search':                   return <><i className="icon-fw icon-magnifier"></i>       { t('Full Text Search Management') }</>;
+      // TODO: Consider where to place the "AuditLog"
+      case 'audit-log':                return <><i className="icon-fw icon-feed"></i>            { t('AuditLog')}</>;
       case 'cloud':                    return <><i className="icon-fw icon-share-alt"></i>       { t('to_cloud_settings')} </>;
       case 'cloud':                    return <><i className="icon-fw icon-share-alt"></i>       { t('to_cloud_settings')} </>;
       default:                         return <><i className="icon-fw icon-home"></i>            { t('Wiki Management Home Page') }</>;
       default:                         return <><i className="icon-fw icon-home"></i>            { t('Wiki Management Home Page') }</>;
     }
     }
@@ -86,6 +88,7 @@ const AdminNavigation = (props) => {
         <MenuLink menu="users"        isListGroupItems isActive={isActiveMenu('/users')} />
         <MenuLink menu="users"        isListGroupItems isActive={isActiveMenu('/users')} />
         <MenuLink menu="user-groups"  isListGroupItems isActive={isActiveMenu('/user-groups')} />
         <MenuLink menu="user-groups"  isListGroupItems isActive={isActiveMenu('/user-groups')} />
         <MenuLink menu="search"       isListGroupItems isActive={isActiveMenu('/search')} />
         <MenuLink menu="search"       isListGroupItems isActive={isActiveMenu('/search')} />
+        <MenuLink menu="audit-log"    isListGroupItems isActive={isActiveMenu('/audit-log')} />
         {growiCloudUri != null && growiAppIdForGrowiCloud != null
         {growiCloudUri != null && growiAppIdForGrowiCloud != null
           && (
           && (
             <a
             <a
@@ -131,6 +134,7 @@ const AdminNavigation = (props) => {
             {isActiveMenu('/users') &&             <MenuLabel menu="users" />}
             {isActiveMenu('/users') &&             <MenuLabel menu="users" />}
             {isActiveMenu('/user-groups') &&       <MenuLabel menu="user-groups" />}
             {isActiveMenu('/user-groups') &&       <MenuLabel menu="user-groups" />}
             {isActiveMenu('/search') &&            <MenuLabel menu="search" />}
             {isActiveMenu('/search') &&            <MenuLabel menu="search" />}
+            {isActiveMenu('/audit-log') &&         <MenuLabel menu="audit-log" />}
           </span>
           </span>
         </button>
         </button>
         <div className="dropdown-menu" aria-labelledby="dropdown-admin-navigation">
         <div className="dropdown-menu" aria-labelledby="dropdown-admin-navigation">

+ 2 - 0
packages/app/src/components/Admin/Security/LdapAuthTest.jsx

@@ -97,6 +97,7 @@ class LdapAuthTest extends React.Component {
               name="username"
               name="username"
               value={this.props.username}
               value={this.props.username}
               onChange={(e) => { this.props.onChangeUsername(e.target.value) }}
               onChange={(e) => { this.props.onChangeUsername(e.target.value) }}
+              autoComplete="off"
             />
             />
           </div>
           </div>
         </div>
         </div>
@@ -109,6 +110,7 @@ class LdapAuthTest extends React.Component {
               name="password"
               name="password"
               value={this.props.password}
               value={this.props.password}
               onChange={(e) => { this.props.onChangePassword(e.target.value) }}
               onChange={(e) => { this.props.onChangePassword(e.target.value) }}
+              autoComplete="off"
             />
             />
           </div>
           </div>
         </div>
         </div>

+ 2 - 4
packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -9,7 +9,7 @@ import { Tooltip } from 'reactstrap';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { apiv3Put } from '~/client/util/apiv3-client';
+import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -234,7 +234,7 @@ const GeneratingTokensAndRegisteringProxyServiceProcess = withUnstatedContainers
 }, [AppContainer]);
 }, [AppContainer]);
 
 
 const TestProcess = ({
 const TestProcess = ({
-  apiv3Post, slackAppIntegrationId, onSubmitForm, onSubmitFormFailed, isLatestConnectionSuccess,
+  slackAppIntegrationId, onSubmitForm, onSubmitFormFailed, isLatestConnectionSuccess,
 }) => {
 }) => {
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -353,7 +353,6 @@ const WithProxyAccordions = (props) => {
     '④': {
     '④': {
       title: 'test_connection',
       title: 'test_connection',
       content: <TestProcess
       content: <TestProcess
-        apiv3Post={props.apiv3Post}
         slackAppIntegrationId={props.slackAppIntegrationId}
         slackAppIntegrationId={props.slackAppIntegrationId}
         onSubmitForm={submitForm}
         onSubmitForm={submitForm}
         onSubmitFormFailed={submitFormFailed}
         onSubmitFormFailed={submitFormFailed}
@@ -397,7 +396,6 @@ const WithProxyAccordions = (props) => {
     '⑥': {
     '⑥': {
       title: 'test_connection',
       title: 'test_connection',
       content: <TestProcess
       content: <TestProcess
-        apiv3Post={props.apiv3Post}
         slackAppIntegrationId={props.slackAppIntegrationId}
         slackAppIntegrationId={props.slackAppIntegrationId}
         onSubmitForm={submitForm}
         onSubmitForm={submitForm}
         onSubmitFormFailed={submitFormFailed}
         onSubmitFormFailed={submitFormFailed}

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

@@ -55,7 +55,7 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
         type="button"
         type="button"
         id="bookmark-button"
         id="bookmark-button"
         onClick={handleClick}
         onClick={handleClick}
-        className={`btn btn-bookmark border-0
+        className={`shadow-none btn btn-bookmark border-0
           ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
           ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
       >
         <i className={`fa ${isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
         <i className={`fa ${isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
@@ -67,7 +67,12 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
 
 
       { !hideTotalNumber && (
       { !hideTotalNumber && (
         <>
         <>
-          <button type="button" id="po-total-bookmarks" className={`btn btn-bookmark border-0 total-bookmarks ${props.isBookmarked ? 'active' : ''}`}>
+          <button
+            type="button"
+            id="po-total-bookmarks"
+            className={`shadow-none btn btn-bookmark border-0
+              total-bookmarks ${props.isBookmarked ? 'active' : ''}`}
+          >
             {bookmarkCount ?? 0}
             {bookmarkCount ?? 0}
           </button>
           </button>
           { bookmarkedUsers != null && (
           { bookmarkedUsers != null && (

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

@@ -153,6 +153,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           <DropdownItem
           <DropdownItem
             onClick={bookmarkItemClickedHandler}
             onClick={bookmarkItemClickedHandler}
             className="grw-page-control-dropdown-item"
             className="grw-page-control-dropdown-item"
+            data-testid="add-remove-bookmark-btn"
           >
           >
             <i className="fa fa-fw fa-bookmark-o grw-page-control-dropdown-icon"></i>
             <i className="fa fa-fw fa-bookmark-o grw-page-control-dropdown-icon"></i>
             { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }
             { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }

+ 9 - 9
packages/app/src/components/Drawio.tsx

@@ -7,13 +7,10 @@ import EventEmitter from 'events';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
-import NotAvailableForGuest from './NotAvailableForGuest';
-
+import { CustomWindow } from '~/interfaces/global';
+import { IGraphViewer } from '~/interfaces/graph-viewer';
 
 
-declare const globalEmitter: EventEmitter;
-declare const GraphViewer: {
-  createViewerForElement: (Element) => void,
-};
+import NotAvailableForGuest from './NotAvailableForGuest';
 
 
 type Props = {
 type Props = {
   drawioContent: string,
   drawioContent: string,
@@ -31,10 +28,13 @@ const Drawio = (props: Props): JSX.Element => {
 
 
   const drawioContainerRef = useRef<HTMLDivElement>(null);
   const drawioContainerRef = useRef<HTMLDivElement>(null);
 
 
+  const globalEmitter: EventEmitter = useMemo(() => (window as CustomWindow).globalEmitter, []);
+  const GraphViewer: IGraphViewer = useMemo(() => (window as CustomWindow).GraphViewer, []);
+
   const editButtonClickHandler = useCallback(() => {
   const editButtonClickHandler = useCallback(() => {
     const { beginLineNumber, endLineNumber } = rangeLineNumberOfMarkdown;
     const { beginLineNumber, endLineNumber } = rangeLineNumberOfMarkdown;
     globalEmitter.emit('launchDrawioModal', beginLineNumber, endLineNumber);
     globalEmitter.emit('launchDrawioModal', beginLineNumber, endLineNumber);
-  }, [rangeLineNumberOfMarkdown]);
+  }, [rangeLineNumberOfMarkdown, globalEmitter]);
 
 
   const renderDrawio = useCallback(() => {
   const renderDrawio = useCallback(() => {
     if (drawioContainerRef.current == null) {
     if (drawioContainerRef.current == null) {
@@ -51,7 +51,7 @@ const Drawio = (props: Props): JSX.Element => {
         GraphViewer.createViewerForElement(div);
         GraphViewer.createViewerForElement(div);
       }
       }
     }
     }
-  }, []);
+  }, [GraphViewer]);
 
 
   const renderDrawioWithDebounce = useMemo(() => debounce(200, renderDrawio), [renderDrawio]);
   const renderDrawioWithDebounce = useMemo(() => debounce(200, renderDrawio), [renderDrawio]);
 
 
@@ -61,7 +61,7 @@ const Drawio = (props: Props): JSX.Element => {
     }
     }
 
 
     renderDrawioWithDebounce();
     renderDrawioWithDebounce();
-  }, [renderDrawioWithDebounce]);
+  }, [renderDrawioWithDebounce, GraphViewer]);
 
 
   return (
   return (
     <div className="editable-with-drawio position-relative">
     <div className="editable-with-drawio position-relative">

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

@@ -51,7 +51,7 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
         type="button"
         type="button"
         id="like-button"
         id="like-button"
         onClick={onLikeClicked}
         onClick={onLikeClicked}
-        className={`btn btn-like border-0
+        className={`shadow-none btn btn-like border-0
             ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
             ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
       >
         <i className={`fa ${isLiked ? 'fa-heart' : 'fa-heart-o'}`}></i>
         <i className={`fa ${isLiked ? 'fa-heart' : 'fa-heart-o'}`}></i>
@@ -63,7 +63,12 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
 
 
       { !hideTotalNumber && (
       { !hideTotalNumber && (
         <>
         <>
-          <button type="button" id="po-total-likes" className={`btn btn-like border-0 total-likes ${isLiked ? 'active' : ''}`}>
+          <button
+            type="button"
+            id="po-total-likes"
+            className={`shadow-none btn btn-like border-0
+              total-likes ${isLiked ? 'active' : ''}`}
+          >
             {sumOfLikers}
             {sumOfLikers}
           </button>
           </button>
           <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-likes" toggle={togglePopover} trigger="legacy">
           <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-likes" toggle={togglePopover} trigger="legacy">

+ 0 - 116
packages/app/src/components/Me/ApiSettings.jsx

@@ -1,116 +0,0 @@
-
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'react-i18next';
-
-import AppContainer from '~/client/services/AppContainer';
-import PersonalContainer from '~/client/services/PersonalContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { apiv3Put } from '~/client/util/apiv3-client';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-
-class ApiSettings extends React.Component {
-
-  constructor(appContainer) {
-    super();
-
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  async onClickSubmit() {
-    const { t, appContainer, personalContainer } = this.props;
-
-    try {
-      await apiv3Put('/personal-setting/api-token');
-
-      await personalContainer.retrievePersonalData();
-      toastSuccess(t('toaster.update_successed', { target: t('page_me_apitoken.api_token') }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-
-  }
-
-  render() {
-    const { t, personalContainer } = this.props;
-    return (
-      <React.Fragment>
-
-        <h2 className="border-bottom my-4">{ t('API Token Settings') }</h2>
-
-        <div className="row mb-3">
-          <label htmlFor="apiToken" className="col-md-3 text-md-right">{t('Current API Token')}</label>
-          <div className="col-md-6">
-            {personalContainer.state.apiToken != null
-              ? (
-                <input
-                  data-testid="grw-api-settings-input"
-                  data-hide-in-vrt
-                  className="form-control"
-                  type="text"
-                  name="apiToken"
-                  value={personalContainer.state.apiToken}
-                  readOnly
-                />
-              )
-              : (
-                <p>
-                  { t('page_me_apitoken.notice.apitoken_issued') }
-                </p>
-              )}
-          </div>
-        </div>
-
-
-        <div className="row">
-          <div className="offset-lg-2 col-lg-7">
-
-            <p className="alert alert-warning">
-              { t('page_me_apitoken.notice.update_token1') }<br />
-              { t('page_me_apitoken.notice.update_token2') }
-            </p>
-
-          </div>
-        </div>
-
-        <div className="row my-3">
-          <div className="offset-4 col-5">
-            <button
-              data-testid="grw-api-settings-update-button"
-              type="button"
-              className="btn btn-primary text-nowrap"
-              onClick={this.onClickSubmit}
-            >
-              {t('Update API Token')}
-            </button>
-          </div>
-        </div>
-
-      </React.Fragment>
-
-    );
-  }
-
-}
-
-ApiSettings.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
-};
-
-const ApiSettingsWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <ApiSettings t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const ApiSettingsWrapper = withUnstatedContainers(ApiSettingsWrapperFC, [AppContainer, PersonalContainer]);
-
-export default ApiSettingsWrapper;

+ 90 - 0
packages/app/src/components/Me/ApiSettings.tsx

@@ -0,0 +1,90 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { useSWRxPersonalSettings, usePersonalSettings } from '~/stores/personal-settings';
+
+
+const ApiSettings = React.memo((): JSX.Element => {
+
+  const { t } = useTranslation();
+  const { mutate: mutateDatabaseData } = useSWRxPersonalSettings();
+  const { data: personalSettingsData } = usePersonalSettings();
+
+  const submitHandler = useCallback(async() => {
+
+    try {
+      await apiv3Put('/personal-setting/api-token');
+      mutateDatabaseData();
+
+      toastSuccess(t('toaster.update_successed', { target: t('page_me_apitoken.api_token') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }, [mutateDatabaseData, t]);
+
+  return (
+    <>
+
+      <h2 className="border-bottom my-4">{ t('API Token Settings') }</h2>
+
+      <div className="row mb-3">
+        <label htmlFor="apiToken" className="col-md-3 text-md-right">{t('Current API Token')}</label>
+        <div className="col-md-6">
+          {personalSettingsData?.apiToken != null
+            ? (
+              <input
+                data-testid="grw-api-settings-input"
+                data-hide-in-vrt
+                className="form-control"
+                type="text"
+                name="apiToken"
+                value={personalSettingsData.apiToken}
+                readOnly
+              />
+            )
+            : (
+              <p>
+                { t('page_me_apitoken.notice.apitoken_issued') }
+              </p>
+            )}
+        </div>
+      </div>
+
+
+      <div className="row">
+        <div className="offset-lg-2 col-lg-7">
+
+          <p className="alert alert-warning">
+            { t('page_me_apitoken.notice.update_token1') }<br />
+            { t('page_me_apitoken.notice.update_token2') }
+          </p>
+
+        </div>
+      </div>
+
+      <div className="row my-3">
+        <div className="offset-4 col-5">
+          <button
+            data-testid="grw-api-settings-update-button"
+            type="button"
+            className="btn btn-primary text-nowrap"
+            onClick={submitHandler}
+          >
+            {t('Update API Token')}
+          </button>
+        </div>
+      </div>
+
+    </>
+
+  );
+
+});
+
+
+export default ApiSettings;

+ 0 - 152
packages/app/src/components/Me/AssociateModal.jsx

@@ -1,152 +0,0 @@
-
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'react-i18next';
-import {
-  Modal,
-  ModalHeader,
-  ModalBody,
-  ModalFooter,
-} from 'reactstrap';
-
-
-import AppContainer from '~/client/services/AppContainer';
-import PersonalContainer from '~/client/services/PersonalContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import LdapAuthTest from '../Admin/Security/LdapAuthTest';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-class AssociateModal extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      username: '',
-      password: '',
-    };
-
-    this.onChangeUsername = this.onChangeUsername.bind(this);
-    this.onChangePassword = this.onChangePassword.bind(this);
-    this.onClickAddBtn = this.onClickAddBtn.bind(this);
-  }
-
-  /**
-   * Change username
-   */
-  onChangeUsername(username) {
-    this.setState({ username });
-  }
-
-  /**
-   * Change password
-   */
-  onChangePassword(password) {
-    this.setState({ password });
-  }
-
-  async onClickAddBtn() {
-    const { t, personalContainer } = this.props;
-    const { username, password } = this.state;
-
-    try {
-      await personalContainer.associateLdapAccount({ username, password });
-      this.props.onClose();
-      toastSuccess(t('security_setting.updated_general_security_setting'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-    try {
-      await personalContainer.retrieveExternalAccounts();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <Modal isOpen={this.props.isOpen} toggle={this.props.onClose} size="lg" data-testid="grw-associate-modal">
-        <ModalHeader className="bg-primary text-light" toggle={this.props.onClose}>
-          { t('admin:user_management.create_external_account') }
-        </ModalHeader>
-        <ModalBody>
-          <ul className="nav nav-tabs passport-settings mb-2" role="tablist">
-            <li className="nav-item active">
-              <a href="#passport-ldap" className="nav-link active" data-toggle="tab" role="tab">
-                <i className="fa fa-sitemap"></i> LDAP
-              </a>
-            </li>
-            <li className="nav-item">
-              <a href="#github-tbd" className="nav-link" data-toggle="tab" role="tab">
-                <i className="fa fa-github"></i> (TBD) GitHub
-              </a>
-            </li>
-            <li className="nav-item">
-              <a href="#google-tbd" className="nav-link" data-toggle="tab" role="tab">
-                <i className="fa fa-google"></i> (TBD) Google OAuth
-              </a>
-            </li>
-            <li className="nav-item">
-              <a href="#facebook-tbd" className="nav-link" data-toggle="tab" role="tab">
-                <i className="fa fa-facebook"></i> (TBD) Facebook
-              </a>
-            </li>
-            <li className="nav-item">
-              <a href="#twitter-tbd" className="nav-link" data-toggle="tab" role="tab">
-                <i className="fa fa-twitter"></i> (TBD) Twitter
-              </a>
-            </li>
-          </ul>
-          <div className="tab-content">
-            <div id="passport-ldap" className="tab-pane active">
-              <LdapAuthTest
-                username={this.state.username}
-                password={this.state.password}
-                onChangeUsername={this.onChangeUsername}
-                onChangePassword={this.onChangePassword}
-              />
-            </div>
-            <div id="github-tbd" className="tab-pane" role="tabpanel">TBD</div>
-            <div id="google-tbd" className="tab-pane" role="tabpanel">TBD</div>
-            <div id="facebook-tbd" className="tab-pane" role="tabpanel">TBD</div>
-            <div id="twitter-tbd" className="tab-pane" role="tabpanel">TBD</div>
-          </div>
-        </ModalBody>
-        <ModalFooter className="border-top-0">
-          <button type="button" className="btn btn-primary mt-3" onClick={this.onClickAddBtn}>
-            <i className="fa fa-plus-circle" aria-hidden="true"></i>
-            {t('add')}
-          </button>
-        </ModalFooter>
-      </Modal>
-    );
-  }
-
-}
-
-AssociateModal.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
-
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func.isRequired,
-};
-
-const AssociateModalWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <AssociateModal t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const AssociateModalWrapper = withUnstatedContainers(AssociateModalWrapperFC, [AppContainer, PersonalContainer]);
-
-export default AssociateModalWrapper;

+ 111 - 0
packages/app/src/components/Me/AssociateModal.tsx

@@ -0,0 +1,111 @@
+import React, { useState, useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  Modal,
+  ModalHeader,
+  ModalBody,
+  ModalFooter,
+} from 'reactstrap';
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { usePersonalSettings, useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
+
+import LdapAuthTest from '../Admin/Security/LdapAuthTest';
+
+type Props = {
+  isOpen: boolean,
+  onClose: () => void,
+}
+
+const AssociateModal = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+  const { mutate: mutatePersonalExternalAccounts } = useSWRxPersonalExternalAccounts();
+  const { associateLdapAccount } = usePersonalSettings();
+  const { isOpen, onClose } = props;
+
+  const [username, setUsername] = useState('');
+  const [password, setPassword] = useState('');
+
+  const closeModalHandler = useCallback(() => {
+    onClose();
+    setUsername('');
+    setPassword('');
+  }, [onClose]);
+
+
+  const clickAddLdapAccountHandler = useCallback(async() => {
+    try {
+      await associateLdapAccount({ username, password });
+      mutatePersonalExternalAccounts();
+
+      closeModalHandler();
+      toastSuccess(t('security_setting.updated_general_security_setting'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }, [associateLdapAccount, closeModalHandler, mutatePersonalExternalAccounts, password, t, username]);
+
+
+  return (
+    <Modal isOpen={isOpen} toggle={closeModalHandler} size="lg" data-testid="grw-associate-modal">
+      <ModalHeader className="bg-primary text-light" toggle={onClose}>
+        { t('admin:user_management.create_external_account') }
+      </ModalHeader>
+      <ModalBody>
+        <ul className="nav nav-tabs passport-settings mb-2" role="tablist">
+          <li className="nav-item active">
+            <a href="#passport-ldap" className="nav-link active" data-toggle="tab" role="tab">
+              <i className="fa fa-sitemap"></i> LDAP
+            </a>
+          </li>
+          <li className="nav-item">
+            <a href="#github-tbd" className="nav-link" data-toggle="tab" role="tab">
+              <i className="fa fa-github"></i> (TBD) GitHub
+            </a>
+          </li>
+          <li className="nav-item">
+            <a href="#google-tbd" className="nav-link" data-toggle="tab" role="tab">
+              <i className="fa fa-google"></i> (TBD) Google OAuth
+            </a>
+          </li>
+          <li className="nav-item">
+            <a href="#facebook-tbd" className="nav-link" data-toggle="tab" role="tab">
+              <i className="fa fa-facebook"></i> (TBD) Facebook
+            </a>
+          </li>
+          <li className="nav-item">
+            <a href="#twitter-tbd" className="nav-link" data-toggle="tab" role="tab">
+              <i className="fa fa-twitter"></i> (TBD) Twitter
+            </a>
+          </li>
+        </ul>
+        <div className="tab-content">
+          <div id="passport-ldap" className="tab-pane active">
+            <LdapAuthTest
+              username={username}
+              password={password}
+              onChangeUsername={username => setUsername(username)}
+              onChangePassword={password => setPassword(password)}
+            />
+          </div>
+          <div id="github-tbd" className="tab-pane" role="tabpanel">TBD</div>
+          <div id="google-tbd" className="tab-pane" role="tabpanel">TBD</div>
+          <div id="facebook-tbd" className="tab-pane" role="tabpanel">TBD</div>
+          <div id="twitter-tbd" className="tab-pane" role="tabpanel">TBD</div>
+        </div>
+      </ModalBody>
+      <ModalFooter className="border-top-0">
+        <button type="button" className="btn btn-primary mt-3" onClick={clickAddLdapAccountHandler}>
+          <i className="fa fa-plus-circle" aria-hidden="true"></i>
+          {t('add')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+
+export default AssociateModal;

+ 0 - 181
packages/app/src/components/Me/BasicInfoSettings.jsx

@@ -1,181 +0,0 @@
-
-import React, { Fragment } from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'react-i18next';
-
-import PersonalContainer from '~/client/services/PersonalContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { localeMetadatas } from '~/client/util/i18n';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-
-class BasicInfoSettings extends React.Component {
-
-  constructor() {
-    super();
-
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  async componentDidMount() {
-    try {
-      await this.props.personalContainer.retrievePersonalData();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  async onClickSubmit() {
-    const { t, personalContainer } = this.props;
-
-    try {
-      await personalContainer.updateBasicInfo();
-      toastSuccess(t('toaster.update_successed', { target: t('Basic Info') }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { t, personalContainer } = this.props;
-    const { registrationWhiteList } = personalContainer.state;
-
-    return (
-      <Fragment>
-
-        <div className="form-group row">
-          <label htmlFor="userForm[name]" className="text-left text-md-right col-md-3 col-form-label">{t('Name')}</label>
-          <div className="col-md-6">
-            <input
-              className="form-control"
-              type="text"
-              name="userForm[name]"
-              defaultValue={personalContainer.state.name}
-              onChange={(e) => { personalContainer.changeName(e.target.value) }}
-            />
-          </div>
-        </div>
-
-        <div className="form-group row">
-          <label htmlFor="userForm[email]" className="text-left text-md-right col-md-3 col-form-label">{t('Email')}</label>
-          <div className="col-md-6">
-            <input
-              className="form-control"
-              type="text"
-              name="userForm[email]"
-              defaultValue={personalContainer.state.email}
-              onChange={(e) => { personalContainer.changeEmail(e.target.value) }}
-            />
-            {registrationWhiteList.length !== 0 && (
-              <div className="form-text text-muted">
-                {t('page_register.form_help.email')}
-                <ul>
-                  {registrationWhiteList.map(data => <li key={data}><code>{data}</code></li>)}
-                </ul>
-              </div>
-            )}
-          </div>
-        </div>
-
-        <div className="form-group row">
-          <label className="text-left text-md-right col-md-3 col-form-label">{t('Disclose E-mail')}</label>
-          <div className="col-md-6">
-            <div className="custom-control custom-radio custom-control-inline">
-              <input
-                type="radio"
-                id="radioEmailShow"
-                className="custom-control-input"
-                name="userForm[isEmailPublished]"
-                checked={personalContainer.state.isEmailPublished}
-                onChange={() => { personalContainer.changeIsEmailPublished(true) }}
-              />
-              <label className="custom-control-label" htmlFor="radioEmailShow">{t('Show')}</label>
-            </div>
-            <div className="custom-control custom-radio custom-control-inline">
-              <input
-                type="radio"
-                id="radioEmailHide"
-                className="custom-control-input"
-                name="userForm[isEmailPublished]"
-                checked={!personalContainer.state.isEmailPublished}
-                onChange={() => { personalContainer.changeIsEmailPublished(false) }}
-              />
-              <label className="custom-control-label" htmlFor="radioEmailHide">{t('Hide')}</label>
-            </div>
-          </div>
-        </div>
-
-        <div className="form-group row">
-          <label className="text-left text-md-right col-md-3 col-form-label">{t('Language')}</label>
-          <div className="col-md-6">
-            {
-              localeMetadatas.map(meta => (
-                <div key={meta.id} className="custom-control custom-radio custom-control-inline">
-                  <input
-                    type="radio"
-                    id={`radioLang${meta.id}`}
-                    className="custom-control-input"
-                    name="userForm[lang]"
-                    checked={personalContainer.state.lang === meta.id}
-                    onChange={() => { personalContainer.changeLang(meta.id) }}
-                  />
-                  <label className="custom-control-label" htmlFor={`radioLang${meta.id}`}>{meta.displayName}</label>
-                </div>
-              ))
-            }
-          </div>
-        </div>
-        <div className="form-group row">
-          <label htmlFor="userForm[slackMemberId]" className="text-left text-md-right col-md-3 col-form-label">{t('Slack Member ID')}</label>
-          <div className="col-md-6">
-            <input
-              className="form-control"
-              type="text"
-              key={personalContainer.state.slackMemberId}
-              name="userForm[slackMemberId]"
-              defaultValue={personalContainer.state.slackMemberId}
-              onChange={(e) => { personalContainer.changeSlackMemberId(e.target.value) }}
-            />
-          </div>
-        </div>
-
-        <div className="row my-3">
-          <div className="offset-4 col-5">
-            <button
-              data-testid="grw-besic-info-settings-update-button"
-              type="button"
-              className="btn btn-primary"
-              onClick={this.onClickSubmit}
-              disabled={personalContainer.state.retrieveError != null}
-            >
-              {t('Update')}
-            </button>
-          </div>
-        </div>
-
-      </Fragment>
-    );
-  }
-
-}
-
-BasicInfoSettings.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
-};
-
-const BasicInfoSettingsWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <BasicInfoSettings t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const BasicInfoSettingsWrapper = withUnstatedContainers(BasicInfoSettingsWrapperFC, [PersonalContainer]);
-
-export default BasicInfoSettingsWrapper;

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

@@ -0,0 +1,171 @@
+import React from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import AppContainer from '~/client/services/AppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { localeMetadatas } from '~/client/util/i18n';
+import { usePersonalSettings } from '~/stores/personal-settings';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+type Props = {
+  appContainer: AppContainer,
+}
+
+const BasicInfoSettings = (props: Props) => {
+  const { t } = useTranslation();
+  const { appContainer } = props;
+
+  const {
+    data: personalSettingsInfo, mutate: mutatePersonalSettings, sync, updateBasicInfo, error,
+  } = usePersonalSettings();
+
+
+  const submitHandler = async() => {
+
+    try {
+      await updateBasicInfo();
+      sync();
+      toastSuccess(t('toaster.update_successed', { target: t('Basic Info') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
+
+  const { registrationWhiteList } = appContainer.getConfig();
+
+  const changePersonalSettingsHandler = (updateData) => {
+    if (personalSettingsInfo == null) {
+      return;
+    }
+    mutatePersonalSettings({ ...personalSettingsInfo, ...updateData });
+  };
+
+
+  return (
+    <>
+
+      <div className="form-group row">
+        <label htmlFor="userForm[name]" className="text-left text-md-right col-md-3 col-form-label">{t('Name')}</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            name="userForm[name]"
+            defaultValue={personalSettingsInfo?.name || ''}
+            onChange={e => changePersonalSettingsHandler({ name: e.target.value })}
+          />
+        </div>
+      </div>
+
+      <div className="form-group row">
+        <label htmlFor="userForm[email]" className="text-left text-md-right col-md-3 col-form-label">{t('Email')}</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            name="userForm[email]"
+            defaultValue={personalSettingsInfo?.email || ''}
+            onChange={e => changePersonalSettingsHandler({ email: e.target.value })}
+          />
+          {registrationWhiteList.length !== 0 && (
+            <div className="form-text text-muted">
+              {t('page_register.form_help.email')}
+              <ul>
+                {registrationWhiteList.map(data => <li key={data}><code>{data}</code></li>)}
+              </ul>
+            </div>
+          )}
+        </div>
+      </div>
+
+      <div className="form-group row">
+        <label className="text-left text-md-right col-md-3 col-form-label">{t('Disclose E-mail')}</label>
+        <div className="col-md-6">
+          <div className="custom-control custom-radio custom-control-inline">
+            <input
+              type="radio"
+              id="radioEmailShow"
+              className="custom-control-input"
+              name="userForm[isEmailPublished]"
+              checked={personalSettingsInfo?.isEmailPublished === true}
+              onChange={() => changePersonalSettingsHandler({ isEmailPublished: true })}
+            />
+            <label className="custom-control-label" htmlFor="radioEmailShow">{t('Show')}</label>
+          </div>
+          <div className="custom-control custom-radio custom-control-inline">
+            <input
+              type="radio"
+              id="radioEmailHide"
+              className="custom-control-input"
+              name="userForm[isEmailPublished]"
+              checked={personalSettingsInfo?.isEmailPublished === false}
+              onChange={() => changePersonalSettingsHandler({ isEmailPublished: false })}
+            />
+            <label className="custom-control-label" htmlFor="radioEmailHide">{t('Hide')}</label>
+          </div>
+        </div>
+      </div>
+
+      <div className="form-group row">
+        <label className="text-left text-md-right col-md-3 col-form-label">{t('Language')}</label>
+        <div className="col-md-6">
+          {
+            localeMetadatas.map(meta => (
+              <div key={meta.id} className="custom-control custom-radio custom-control-inline">
+                <input
+                  type="radio"
+                  id={`radioLang${meta.id}`}
+                  className="custom-control-input"
+                  name="userForm[lang]"
+                  checked={personalSettingsInfo?.lang === meta.id}
+                  onChange={() => changePersonalSettingsHandler({ lang: meta.id })}
+                />
+                <label className="custom-control-label" htmlFor={`radioLang${meta.id}`}>{meta.displayName}</label>
+              </div>
+            ))
+          }
+        </div>
+      </div>
+      <div className="form-group row">
+        <label htmlFor="userForm[slackMemberId]" className="text-left text-md-right col-md-3 col-form-label">{t('Slack Member ID')}</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            key="slackMemberId"
+            name="userForm[slackMemberId]"
+            defaultValue={personalSettingsInfo?.slackMemberId || ''}
+            onChange={e => changePersonalSettingsHandler({ slackMemberId: e.target.value })}
+          />
+        </div>
+      </div>
+
+      <div className="row my-3">
+        <div className="offset-4 col-5">
+          <button
+            data-testid="grw-besic-info-settings-update-button"
+            type="button"
+            className="btn btn-primary"
+            onClick={submitHandler}
+            disabled={error != null}
+          >
+            {t('Update')}
+          </button>
+        </div>
+      </div>
+
+    </>
+  );
+};
+
+
+/**
+ * Wrapper component for using unstated
+ */
+const BasicInfoSettingsWrapper = withUnstatedContainers(BasicInfoSettings, [AppContainer]);
+
+export default BasicInfoSettingsWrapper;

+ 0 - 98
packages/app/src/components/Me/DisassociateModal.jsx

@@ -1,98 +0,0 @@
-
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'react-i18next';
-import {
-  Modal,
-  ModalHeader,
-  ModalBody,
-  ModalFooter,
-} from 'reactstrap';
-
-import AppContainer from '~/client/services/AppContainer';
-import PersonalContainer from '~/client/services/PersonalContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-
-class DisassociateModal extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.onClickDisassociateBtn = this.onClickDisassociateBtn.bind(this);
-  }
-
-  async onClickDisassociateBtn() {
-    const { t, personalContainer } = this.props;
-    const { providerType, accountId } = this.props.accountForDisassociate;
-
-    try {
-      await personalContainer.disassociateLdapAccount({ providerType, accountId });
-      this.props.onClose();
-      toastSuccess(t('security_setting.updated_general_security_setting'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-    try {
-      await personalContainer.retrieveExternalAccounts();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { t, accountForDisassociate } = this.props;
-    const { providerType, accountId } = accountForDisassociate;
-
-    return (
-      <Modal isOpen={this.props.isOpen} toggle={this.props.onClose}>
-        <ModalHeader className="bg-info text-light" toggle={this.props.onClose}>
-          {t('personal_settings.disassociate_external_account')}
-        </ModalHeader>
-        <ModalBody>
-          {/* eslint-disable-next-line react/no-danger */}
-          <p dangerouslySetInnerHTML={{ __html: t('personal_settings.disassociate_external_account_desc', { providerType, accountId }) }} />
-        </ModalBody>
-        <ModalFooter>
-          <button type="button" className="btn btn-sm btn-outline-secondary" onClick={this.props.onClose}>
-            { t('Cancel') }
-          </button>
-          <button type="button" className="btn btn-sm btn-danger" onClick={this.onClickDisassociateBtn}>
-            <i className="ti-unlink"></i>
-            { t('Disassociate') }
-          </button>
-        </ModalFooter>
-      </Modal>
-    );
-  }
-
-}
-
-DisassociateModal.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
-
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func.isRequired,
-  accountForDisassociate: PropTypes.object.isRequired,
-
-};
-
-const DisassociateModalWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <DisassociateModal t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const DisassociateModalWrapper = withUnstatedContainers(DisassociateModalWrapperFC, [AppContainer, PersonalContainer]);
-
-
-export default DisassociateModalWrapper;

+ 69 - 0
packages/app/src/components/Me/DisassociateModal.tsx

@@ -0,0 +1,69 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  Modal,
+  ModalHeader,
+  ModalBody,
+  ModalFooter,
+} from 'reactstrap';
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { IExternalAccount } from '~/interfaces/external-account';
+import { usePersonalSettings, useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
+
+type Props = {
+  isOpen: boolean,
+  onClose: () => void,
+  accountForDisassociate: IExternalAccount,
+}
+
+
+const DisassociateModal = (props: Props): JSX.Element => {
+
+  const { t } = useTranslation();
+  const { mutate: mutatePersonalExternalAccounts } = useSWRxPersonalExternalAccounts();
+  const { disassociateLdapAccount } = usePersonalSettings();
+
+  const { providerType, accountId } = props.accountForDisassociate;
+
+  const disassociateAccountHandler = useCallback(async() => {
+
+    try {
+      await disassociateLdapAccount({ providerType, accountId });
+      props.onClose();
+      toastSuccess(t('security_setting.updated_general_security_setting'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+    if (mutatePersonalExternalAccounts != null) {
+      mutatePersonalExternalAccounts();
+    }
+  }, [accountId, disassociateLdapAccount, mutatePersonalExternalAccounts, props, providerType, t]);
+
+  return (
+    <Modal isOpen={props.isOpen} toggle={props.onClose}>
+      <ModalHeader className="bg-info text-light" toggle={props.onClose}>
+        {t('personal_settings.disassociate_external_account')}
+      </ModalHeader>
+      <ModalBody>
+        {/* eslint-disable-next-line react/no-danger */}
+        <p dangerouslySetInnerHTML={{ __html: t('personal_settings.disassociate_external_account_desc', { providerType, accountId }) }} />
+      </ModalBody>
+      <ModalFooter>
+        <button type="button" className="btn btn-sm btn-outline-secondary" onClick={props.onClose}>
+          { t('Cancel') }
+        </button>
+        <button type="button" className="btn btn-sm btn-danger" onClick={disassociateAccountHandler}>
+          <i className="ti-unlink"></i>
+          { t('Disassociate') }
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+
+export default DisassociateModal;

+ 8 - 18
packages/app/src/components/Me/ExternalAccountLinkedMe.jsx

@@ -1,4 +1,3 @@
-
 import React, { Fragment } from 'react';
 import React, { Fragment } from 'react';
 
 
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
@@ -6,8 +5,7 @@ import { useTranslation } from 'react-i18next';
 
 
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import PersonalContainer from '~/client/services/PersonalContainer';
-import { toastError } from '~/client/util/apiNotification';
+import { useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 
@@ -32,15 +30,6 @@ class ExternalAccountLinkedMe extends React.Component {
     this.closeDisassociateModal = this.closeDisassociateModal.bind(this);
     this.closeDisassociateModal = this.closeDisassociateModal.bind(this);
   }
   }
 
 
-  async componentDidMount() {
-    try {
-      await this.props.personalContainer.retrieveExternalAccounts();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
   openAssociateModal() {
   openAssociateModal() {
     this.setState({ isAssociateModalOpen: true });
     this.setState({ isAssociateModalOpen: true });
   }
   }
@@ -65,8 +54,7 @@ class ExternalAccountLinkedMe extends React.Component {
   }
   }
 
 
   render() {
   render() {
-    const { t, personalContainer } = this.props;
-    const { externalAccounts } = personalContainer.state;
+    const { t, personalExternalAccounts } = this.props;
 
 
     return (
     return (
       <Fragment>
       <Fragment>
@@ -95,7 +83,7 @@ class ExternalAccountLinkedMe extends React.Component {
             </tr>
             </tr>
           </thead>
           </thead>
           <tbody>
           <tbody>
-            {externalAccounts !== 0 && externalAccounts.map(account => (
+            {personalExternalAccounts != null && personalExternalAccounts.length > 0 && personalExternalAccounts.map(account => (
               <ExternalAccountRow
               <ExternalAccountRow
                 account={account}
                 account={account}
                 key={account._id}
                 key={account._id}
@@ -128,17 +116,19 @@ class ExternalAccountLinkedMe extends React.Component {
 ExternalAccountLinkedMe.propTypes = {
 ExternalAccountLinkedMe.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
+  personalExternalAccounts: PropTypes.arrayOf(PropTypes.object),
 };
 };
 
 
 const ExternalAccountLinkedMeWrapperFC = (props) => {
 const ExternalAccountLinkedMeWrapperFC = (props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  return <ExternalAccountLinkedMe t={t} {...props} />;
+  const { data: personalExternalAccountsData } = useSWRxPersonalExternalAccounts();
+
+  return <ExternalAccountLinkedMe t={t} personalExternalAccounts={personalExternalAccountsData} {...props} />;
 };
 };
 
 
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const ExternalAccountLinkedMeWrapper = withUnstatedContainers(ExternalAccountLinkedMeWrapperFC, [AppContainer, PersonalContainer]);
+const ExternalAccountLinkedMeWrapper = withUnstatedContainers(ExternalAccountLinkedMeWrapperFC, [AppContainer]);
 
 
 export default ExternalAccountLinkedMeWrapper;
 export default ExternalAccountLinkedMeWrapper;

+ 19 - 17
packages/app/src/components/Me/PasswordSettings.jsx

@@ -1,14 +1,12 @@
-
-import React from 'react';
+import React, { useCallback } from 'react';
 
 
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
-import PersonalContainer from '~/client/services/PersonalContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
+import { usePersonalSettings } from '~/stores/personal-settings';
 
 
-import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 class PasswordSettings extends React.Component {
 class PasswordSettings extends React.Component {
 
 
@@ -24,7 +22,7 @@ class PasswordSettings extends React.Component {
       minPasswordLength: null,
       minPasswordLength: null,
     };
     };
 
 
-    this.onClickSubmit = this.onClickSubmit.bind(this);
+    this.submitHandler = this.submitHandler.bind(this);
     this.onChangeOldPassword = this.onChangeOldPassword.bind(this);
     this.onChangeOldPassword = this.onChangeOldPassword.bind(this);
 
 
   }
   }
@@ -42,8 +40,8 @@ class PasswordSettings extends React.Component {
 
 
   }
   }
 
 
-  async onClickSubmit() {
-    const { t, personalContainer } = this.props;
+  async submitHandler() {
+    const { t, onSubmit } = this.props;
     const { oldPassword, newPassword, newPasswordConfirm } = this.state;
     const { oldPassword, newPassword, newPasswordConfirm } = this.state;
 
 
     try {
     try {
@@ -51,7 +49,9 @@ class PasswordSettings extends React.Component {
         oldPassword, newPassword, newPasswordConfirm,
         oldPassword, newPassword, newPasswordConfirm,
       });
       });
       this.setState({ oldPassword: '', newPassword: '', newPasswordConfirm: '' });
       this.setState({ oldPassword: '', newPassword: '', newPasswordConfirm: '' });
-      await personalContainer.retrievePersonalData();
+      if (onSubmit != null) {
+        onSubmit();
+      }
       toastSuccess(t('toaster.update_successed', { target: t('Password') }));
       toastSuccess(t('toaster.update_successed', { target: t('Password') }));
     }
     }
     catch (err) {
     catch (err) {
@@ -140,7 +140,7 @@ class PasswordSettings extends React.Component {
               data-testid="grw-password-settings-update-button"
               data-testid="grw-password-settings-update-button"
               type="button"
               type="button"
               className="btn btn-primary"
               className="btn btn-primary"
-              onClick={this.onClickSubmit}
+              onClick={this.submitHandler}
               disabled={isIncorrectConfirmPassword}
               disabled={isIncorrectConfirmPassword}
             >
             >
               {t('Update')}
               {t('Update')}
@@ -155,17 +155,19 @@ class PasswordSettings extends React.Component {
 
 
 PasswordSettings.propTypes = {
 PasswordSettings.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
-  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
+  onSubmit: PropTypes.func,
 };
 };
 
 
 const PasswordSettingsWrapperFC = (props) => {
 const PasswordSettingsWrapperFC = (props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  return <PasswordSettings t={t} {...props} />;
-};
+  const { mutate: mutatePersonalSettings } = usePersonalSettings();
+
+  const submitHandler = useCallback(() => {
+    mutatePersonalSettings();
+  }, [mutatePersonalSettings]);
 
 
-/**
- * Wrapper component for using unstated
- */
-const PasswordSettingsWrapper = withUnstatedContainers(PasswordSettingsWrapperFC, [PersonalContainer]);
 
 
-export default PasswordSettingsWrapper;
+  return <PasswordSettings t={t} onSubmit={submitHandler} {...props} />;
+};
+
+export default PasswordSettingsWrapperFC;

+ 2 - 1
packages/app/src/components/Me/PersonalSettings.jsx

@@ -58,10 +58,11 @@ const PersonalSettings = () => {
     };
     };
   }, [t]);
   }, [t]);
 
 
+  const onPasswordSettings = window.location.hash === '#password';
 
 
   return (
   return (
     <div data-testid="grw-personal-settings">
     <div data-testid="grw-personal-settings">
-      <CustomNavAndContents navTabMapping={navTabMapping} navigationMode="both" tabContentClasses={['px-0']} />
+      <CustomNavAndContents defaultTabIndex={onPasswordSettings && 2} navTabMapping={navTabMapping} navigationMode="both" tabContentClasses={['px-0']} />
     </div>
     </div>
   );
   );
 
 

+ 8 - 1
packages/app/src/components/Navbar/GlobalSearch.tsx

@@ -73,7 +73,13 @@ const GlobalSearch: FC<Props> = (props: Props) => {
     <div className={`form-group mb-0 d-print-none ${isSearchServiceReachable ? '' : 'has-error'}`}>
     <div className={`form-group mb-0 d-print-none ${isSearchServiceReachable ? '' : 'has-error'}`}>
       <div className="input-group flex-nowrap">
       <div className="input-group flex-nowrap">
         <div className={`input-group-prepend ${dropup ? 'dropup' : ''}`}>
         <div className={`input-group-prepend ${dropup ? 'dropup' : ''}`}>
-          <button className="btn btn-secondary dropdown-toggle py-0" type="button" data-toggle="dropdown" aria-haspopup="true">
+          <button
+            className="btn btn-secondary dropdown-toggle py-0"
+            type="button"
+            data-toggle="dropdown"
+            aria-haspopup="true"
+            data-testid="select-search-scope"
+          >
             {scopeLabel}
             {scopeLabel}
           </button>
           </button>
           <div className="dropdown-menu">
           <div className="dropdown-menu">
@@ -88,6 +94,7 @@ const GlobalSearch: FC<Props> = (props: Props) => {
               { t('header_search_box.item_label.All pages') }
               { t('header_search_box.item_label.All pages') }
             </button>
             </button>
             <button
             <button
+              data-tesid="search-current-tree"
               className="dropdown-item"
               className="dropdown-item"
               type="button"
               type="button"
               onClick={() => {
               onClick={() => {

+ 4 - 6
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -155,12 +155,10 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
   return (
   return (
     <div className="d-flex" style={{ gap: '2px' }}>
     <div className="d-flex" style={{ gap: '2px' }}>
       {revisionId != null && (
       {revisionId != null && (
-        <span>
-          <SubscribeButton
-            status={pageInfo.subscriptionStatus}
-            onClick={subscribeClickhandler}
-          />
-        </span>
+        <SubscribeButton
+          status={pageInfo.subscriptionStatus}
+          onClick={subscribeClickhandler}
+        />
       )}
       )}
       {revisionId != null && (
       {revisionId != null && (
         <LikeButtons
         <LikeButtons

+ 0 - 4
packages/app/src/components/Page/RevisionBody.jsx

@@ -60,9 +60,6 @@ export default class RevisionBody extends React.PureComponent {
       <div
       <div
         ref={(elem) => {
         ref={(elem) => {
           this.element = elem;
           this.element = elem;
-          if (this.props.inputRef != null) {
-            this.props.inputRef.current = elem;
-          }
         }}
         }}
         id="wiki"
         id="wiki"
         className={`wiki ${additionalClassName}`}
         className={`wiki ${additionalClassName}`}
@@ -76,7 +73,6 @@ export default class RevisionBody extends React.PureComponent {
 
 
 RevisionBody.propTypes = {
 RevisionBody.propTypes = {
   html: PropTypes.string,
   html: PropTypes.string,
-  inputRef: PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
   isMathJaxEnabled: PropTypes.bool,
   isMathJaxEnabled: PropTypes.bool,
   renderMathJaxOnInit: PropTypes.bool,
   renderMathJaxOnInit: PropTypes.bool,
   renderMathJaxInRealtime: PropTypes.bool,
   renderMathJaxInRealtime: PropTypes.bool,

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

@@ -38,11 +38,10 @@ const TagsInput: FC<Props> = (props: Props) => {
   const searchHandler = useCallback(async(query: string) => {
   const searchHandler = useCallback(async(query: string) => {
     const tagsSearchData = tagsSearch?.tags || [];
     const tagsSearchData = tagsSearch?.tags || [];
     setSearchQuery(query);
     setSearchQuery(query);
-
-    tagsSearchData.unshift(searchQuery);
+    tagsSearchData.unshift(query);
     setResultTags(Array.from(new Set(tagsSearchData)));
     setResultTags(Array.from(new Set(tagsSearchData)));
 
 
-  }, [searchQuery, tagsSearch?.tags]);
+  }, [tagsSearch?.tags]);
 
 
   const keyDownHandler = useCallback((event: React.KeyboardEvent) => {
   const keyDownHandler = useCallback((event: React.KeyboardEvent) => {
     if (event.key === ' ') {
     if (event.key === ' ') {

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

@@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
-import { useCurrentUpdatedAt, useShareLinkId } from '~/stores/context';
+import { useCurrentUpdatedAt, useIsTrashPage, useShareLinkId } from '~/stores/context';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import { useSWRxPageInfo } from '~/stores/page';
 import { useSWRxPageInfo } from '~/stores/page';
 import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
 import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
@@ -24,7 +24,7 @@ const TrashPageAlert = (props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { pageContainer } = props;
   const { pageContainer } = props;
   const {
   const {
-    pageId, revisionId, path, isDeleted, lastUpdateUsername, deletedUserName, deletedAt,
+    pageId, revisionId, path, lastUpdateUsername, deletedUserName, deletedAt,
   } = pageContainer.state;
   } = pageContainer.state;
 
 
   const { data: isAbleToShowTrashPageManagementButtons } = useIsAbleToShowTrashPageManagementButtons();
   const { data: isAbleToShowTrashPageManagementButtons } = useIsAbleToShowTrashPageManagementButtons();
@@ -38,6 +38,7 @@ const TrashPageAlert = (props) => {
   const { data: pageInfo } = useSWRxPageInfo(pageId ?? null, shareLinkId);
   const { data: pageInfo } = useSWRxPageInfo(pageId ?? null, shareLinkId);
 
 
   const { data: updatedAt } = useCurrentUpdatedAt();
   const { data: updatedAt } = useCurrentUpdatedAt();
+  const { data: isTrashPage } = useIsTrashPage();
 
 
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
@@ -89,7 +90,7 @@ const TrashPageAlert = (props) => {
       <div className="alert alert-warning py-3 pl-4 d-flex flex-column flex-lg-row">
       <div className="alert alert-warning py-3 pl-4 d-flex flex-column flex-lg-row">
         <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>.
-          {isDeleted && (
+          {isTrashPage && (
             <>
             <>
               <br />
               <br />
               <UserPicture user={{ username: deletedUserName || lastUpdateUsername }} />
               <UserPicture user={{ username: deletedUserName || lastUpdateUsername }} />

+ 7 - 7
packages/app/src/components/PageAccessoriesModal.tsx

@@ -1,25 +1,25 @@
 import React, { useEffect, useMemo, useState } from 'react';
 import React, { useEffect, useMemo, useState } from 'react';
 
 
+import { useTranslation } from 'react-i18next';
 import {
 import {
   Modal, ModalBody, ModalHeader,
   Modal, ModalBody, ModalHeader,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import { useTranslation } from 'react-i18next';
 
 
+import AppContainer from '~/client/services/AppContainer';
 import { useIsGuestUser, useIsSharedUser } from '~/stores/context';
 import { useIsGuestUser, useIsSharedUser } from '~/stores/context';
 import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/modal';
 import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/modal';
-import AppContainer from '~/client/services/AppContainer';
 
 
-import HistoryIcon from './Icons/HistoryIcon';
+import { CustomNavTab } from './CustomNavigation/CustomNav';
+import CustomTabContent from './CustomNavigation/CustomTabContent';
+import ExpandOrContractButton from './ExpandOrContractButton';
 import AttachmentIcon from './Icons/AttachmentIcon';
 import AttachmentIcon from './Icons/AttachmentIcon';
+import HistoryIcon from './Icons/HistoryIcon';
 import ShareLinkIcon from './Icons/ShareLinkIcon';
 import ShareLinkIcon from './Icons/ShareLinkIcon';
-import { withUnstatedContainers } from './UnstatedUtils';
 import PageAttachment from './PageAttachment';
 import PageAttachment from './PageAttachment';
 import PageHistory from './PageHistory';
 import PageHistory from './PageHistory';
 import ShareLink from './ShareLink/ShareLink';
 import ShareLink from './ShareLink/ShareLink';
-import { CustomNavTab } from './CustomNavigation/CustomNav';
-import ExpandOrContractButton from './ExpandOrContractButton';
-import CustomTabContent from './CustomNavigation/CustomTabContent';
+import { withUnstatedContainers } from './UnstatedUtils';
 
 
 
 
 type Props = {
 type Props = {

+ 0 - 464
packages/app/src/components/PageComment/CommentEditor.jsx

@@ -1,464 +0,0 @@
-import React, { useCallback } from 'react';
-
-import { UserPicture } from '@growi/ui';
-import PropTypes from 'prop-types';
-import {
-  Button,
-  TabContent, TabPane,
-} from 'reactstrap';
-import * as toastr from 'toastr';
-
-import AppContainer from '~/client/services/AppContainer';
-import CommentContainer from '~/client/services/CommentContainer';
-import EditorContainer from '~/client/services/EditorContainer';
-import PageContainer from '~/client/services/PageContainer';
-import GrowiRenderer from '~/client/util/GrowiRenderer';
-import { useCurrentPagePath, useCurrentUser } from '~/stores/context';
-import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
-import { useIsMobile } from '~/stores/ui';
-
-import { CustomNavTab } from '../CustomNavigation/CustomNav';
-import NotAvailableForGuest from '../NotAvailableForGuest';
-import Editor from '../PageEditor/Editor';
-import { SlackNotification } from '../SlackNotification';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-import CommentPreview from './CommentPreview';
-
-
-const navTabMapping = {
-  comment_editor: {
-    Icon: () => <i className="icon-settings" />,
-    i18n: 'Write',
-    index: 0,
-  },
-  comment_preview: {
-    Icon: () => <i className="icon-settings" />,
-    i18n: 'Preview',
-    index: 1,
-  },
-};
-
-/**
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @extends {React.Component}
- */
-
-class CommentEditor extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    const config = this.props.appContainer.getConfig();
-    const isUploadable = config.upload.image || config.upload.file;
-    const isUploadableFile = config.upload.file;
-
-    this.state = {
-      isReadyToUse: !this.props.isForNewComment,
-      comment: this.props.commentBody || '',
-      isMarkdown: true,
-      html: '',
-      activeTab: 'comment_editor',
-      isUploadable,
-      isUploadableFile,
-      errorMessage: undefined,
-      isSlackConfigured: config.isSlackConfigured,
-      slackChannels: this.props.slackChannels,
-    };
-
-    this.updateState = this.updateState.bind(this);
-    this.updateStateCheckbox = this.updateStateCheckbox.bind(this);
-
-    this.cancelButtonClickedHandler = this.cancelButtonClickedHandler.bind(this);
-    this.commentButtonClickedHandler = this.commentButtonClickedHandler.bind(this);
-    this.ctrlEnterHandler = this.ctrlEnterHandler.bind(this);
-    this.postComment = this.postComment.bind(this);
-    this.uploadHandler = this.uploadHandler.bind(this);
-
-    this.renderHtml = this.renderHtml.bind(this);
-    this.handleSelect = this.handleSelect.bind(this);
-    this.onSlackChannelsChange = this.onSlackChannelsChange.bind(this);
-    this.fetchSlackChannels = this.fetchSlackChannels.bind(this);
-  }
-
-  updateState(value) {
-    this.setState({ comment: value });
-  }
-
-  updateStateCheckbox(event) {
-    const value = event.target.checked;
-    this.setState({ isMarkdown: value });
-    // changeMode
-    this.editor.setGfmMode(value);
-  }
-
-  handleSelect(activeTab) {
-    this.setState({ activeTab });
-    this.renderHtml(this.state.comment);
-  }
-
-  fetchSlackChannels(slackChannels) {
-    this.setState({ slackChannels });
-  }
-
-  componentDidUpdate(prevProps) {
-    if (this.props.slackChannels !== prevProps.slackChannels) {
-      this.fetchSlackChannels(this.props.slackChannels);
-    }
-  }
-
-  onSlackChannelsChange(slackChannels) {
-    this.setState({ slackChannels });
-  }
-
-  initializeEditor() {
-    this.setState({
-      comment: '',
-      isMarkdown: true,
-      html: '',
-      activeTab: 'comment_editor',
-      errorMessage: undefined,
-    });
-    // reset value
-    this.editor.setValue('');
-  }
-
-  cancelButtonClickedHandler() {
-    const { isForNewComment, onCancelButtonClicked } = this.props;
-
-    // change state to not ready
-    // when this editor is for the new comment mode
-    if (isForNewComment) {
-      this.setState({ isReadyToUse: false });
-    }
-
-    if (onCancelButtonClicked != null) {
-      const { replyTo, currentCommentId } = this.props;
-      onCancelButtonClicked(replyTo || currentCommentId);
-    }
-  }
-
-  commentButtonClickedHandler() {
-    this.postComment();
-  }
-
-  ctrlEnterHandler(event) {
-    if (event != null) {
-      event.preventDefault();
-    }
-
-    this.postComment();
-  }
-
-  /**
-   * Post comment with CommentContainer and update state
-   */
-  async postComment() {
-    const {
-      commentContainer, replyTo, currentCommentId, commentCreator, onCommentButtonClicked,
-    } = this.props;
-    try {
-      if (currentCommentId != null) {
-        await commentContainer.putComment(
-          this.state.comment,
-          this.state.isMarkdown,
-          currentCommentId,
-          commentCreator,
-        );
-      }
-      else {
-        await this.props.commentContainer.postComment(
-          this.state.comment,
-          this.state.isMarkdown,
-          replyTo,
-          this.props.isSlackEnabled,
-          this.state.slackChannels,
-        );
-      }
-      this.initializeEditor();
-
-      if (onCommentButtonClicked != null) {
-        onCommentButtonClicked();
-      }
-    }
-    catch (err) {
-      const errorMessage = err.message || 'An unknown error occured when posting comment';
-      this.setState({ errorMessage });
-    }
-  }
-
-  uploadHandler(file) {
-    this.props.commentContainer.uploadAttachment(file)
-      .then((res) => {
-        const attachment = res.attachment;
-        const fileName = attachment.originalName;
-
-        let insertText = `[${fileName}](${attachment.filePathProxied})`;
-        // when image
-        if (attachment.fileFormat.startsWith('image/')) {
-          // modify to "![fileName](url)" syntax
-          insertText = `!${insertText}`;
-        }
-        this.editor.insertText(insertText);
-      })
-      .catch(this.apiErrorHandler)
-      // finally
-      .then(() => {
-        this.editor.terminateUploadingState();
-      });
-  }
-
-  apiErrorHandler(error) {
-    toastr.error(error.message, 'Error occured', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '3000',
-    });
-  }
-
-  getCommentHtml() {
-    return (
-      <CommentPreview
-        html={this.state.html}
-      />
-    );
-  }
-
-  renderHtml(markdown) {
-    const context = {
-      markdown,
-    };
-
-    const { growiRenderer } = this.props;
-    const { interceptorManager } = window;
-    interceptorManager.process('preRenderCommnetPreview', context)
-      .then(() => { return interceptorManager.process('prePreProcess', context) })
-      .then(() => {
-        context.markdown = growiRenderer.preProcess(context.markdown, context);
-      })
-      .then(() => { return interceptorManager.process('postPreProcess', context) })
-      .then(() => {
-        const parsedHTML = growiRenderer.process(context.markdown, context);
-        context.parsedHTML = parsedHTML;
-      })
-      .then(() => { return interceptorManager.process('prePostProcess', context) })
-      .then(() => {
-        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context);
-      })
-      .then(() => { return interceptorManager.process('postPostProcess', context) })
-      .then(() => { return interceptorManager.process('preRenderCommentPreviewHtml', context) })
-      .then(() => {
-        this.setState({ html: context.parsedHTML });
-      })
-      // process interceptors for post rendering
-      .then(() => { return interceptorManager.process('postRenderCommentPreviewHtml', context) });
-  }
-
-  generateInnerHtml(html) {
-    return { __html: html };
-  }
-
-  renderBeforeReady() {
-    return (
-      <div className="text-center">
-        <NotAvailableForGuest>
-          <button
-            type="button"
-            className="btn btn-lg btn-link"
-            onClick={() => this.setState({ isReadyToUse: true })}
-          >
-            <i className="icon-bubble"></i> Add Comment
-          </button>
-        </NotAvailableForGuest>
-      </div>
-    );
-  }
-
-  renderReady() {
-    const { isMobile } = this.props;
-    const { activeTab } = this.state;
-
-    const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : null;
-
-    const errorMessage = <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>;
-    const cancelButton = (
-      <Button outline color="danger" size="xs" className="btn btn-outline-danger rounded-pill" onClick={this.cancelButtonClickedHandler}>
-        Cancel
-      </Button>
-    );
-    const submitButton = (
-      <Button
-        outline
-        color="primary"
-        className="btn btn-outline-primary rounded-pill"
-        onClick={this.commentButtonClickedHandler}
-      >
-        Comment
-      </Button>
-    );
-
-
-    return (
-      <>
-        <div className="comment-write">
-          <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={this.handleSelect} hideBorderBottom />
-          <TabContent activeTab={activeTab}>
-            <TabPane tabId="comment_editor">
-              <Editor
-                ref={(c) => { this.editor = c }}
-                value={this.state.comment}
-                isGfmMode={this.state.isMarkdown}
-                lineNumbers={false}
-                isMobile={isMobile}
-                isUploadable={this.state.isUploadable}
-                isUploadableFile={this.state.isUploadableFile}
-                onChange={this.updateState}
-                onUpload={this.uploadHandler}
-                onCtrlEnter={this.ctrlEnterHandler}
-                isComment
-              />
-              {/*
-                Note: <OptionsSelector /> is not optimized for ComentEditor in terms of responsive design.
-                See a review comment in https://github.com/weseek/growi/pull/3473
-              */}
-            </TabPane>
-            <TabPane tabId="comment_preview">
-              <div className="comment-form-preview">
-                {commentPreview}
-              </div>
-            </TabPane>
-          </TabContent>
-        </div>
-
-        <div className="comment-submit">
-          <div className="d-flex">
-            <label className="mr-2">
-              {activeTab === 'comment_editor' && (
-                <span className="custom-control custom-checkbox">
-                  <input
-                    type="checkbox"
-                    className="custom-control-input"
-                    id="comment-form-is-markdown"
-                    name="isMarkdown"
-                    checked={this.state.isMarkdown}
-                    value="1"
-                    onChange={this.updateStateCheckbox}
-                  />
-                  <label
-                    className="ml-2 custom-control-label"
-                    htmlFor="comment-form-is-markdown"
-                  >
-                    Markdown
-                  </label>
-                </span>
-              ) }
-            </label>
-            <span className="flex-grow-1" />
-            <span className="d-none d-sm-inline">{ this.state.errorMessage && errorMessage }</span>
-
-            { this.state.isSlackConfigured
-              && (
-                <div className="form-inline align-self-center mr-md-2">
-                  <SlackNotification
-                    isSlackEnabled={this.props.isSlackEnabled}
-                    slackChannels={this.state.slackChannels}
-                    onEnabledFlagChange={this.props.onSlackEnabledFlagChange}
-                    onChannelChange={this.onSlackChannelsChange}
-                    id="idForComment"
-                  />
-                </div>
-              )
-            }
-            <div className="d-none d-sm-block">
-              <span className="mr-2">{cancelButton}</span><span>{submitButton}</span>
-            </div>
-          </div>
-          <div className="d-block d-sm-none mt-2">
-            <div className="d-flex justify-content-end">
-              { this.state.errorMessage && errorMessage }
-              <span className="mr-2">{cancelButton}</span><span>{submitButton}</span>
-            </div>
-          </div>
-        </div>
-      </>
-    );
-  }
-
-  render() {
-    const { currentUser } = this.props;
-    const { isReadyToUse } = this.state;
-
-    return (
-      <div className="form page-comment-form">
-        <div className="comment-form">
-          <div className="comment-form-user">
-            <UserPicture user={currentUser} noLink noTooltip />
-          </div>
-          <div className="comment-form-main">
-            { !isReadyToUse
-              ? this.renderBeforeReady()
-              : this.renderReady()
-            }
-          </div>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const CommentEditorHOCWrapper = withUnstatedContainers(CommentEditor, [AppContainer, PageContainer, EditorContainer, CommentContainer]);
-
-CommentEditor.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-  commentContainer: PropTypes.instanceOf(CommentContainer).isRequired,
-
-  slackChannels: PropTypes.string.isRequired,
-  isSlackEnabled: PropTypes.bool.isRequired,
-  growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
-  currentUser: PropTypes.instanceOf(Object),
-  isMobile: PropTypes.bool,
-  isForNewComment: PropTypes.bool,
-  replyTo: PropTypes.string,
-  currentCommentId: PropTypes.string,
-  commentBody: PropTypes.string,
-  commentCreator: PropTypes.string,
-  onCancelButtonClicked: PropTypes.func,
-  onCommentButtonClicked: PropTypes.func,
-  onSlackEnabledFlagChange: PropTypes.func,
-};
-
-const CommentEditorWrapper = (props) => {
-  const { data: isMobile } = useIsMobile();
-  const { data: currentUser } = useCurrentUser();
-  const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
-  const { data: currentPagePath } = useCurrentPagePath();
-  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
-
-  const onSlackEnabledFlagChange = useCallback((isSlackEnabled) => {
-    mutateIsSlackEnabled(isSlackEnabled, false);
-  }, [mutateIsSlackEnabled]);
-
-  return (
-    <CommentEditorHOCWrapper
-      {...props}
-      onSlackEnabledFlagChange={onSlackEnabledFlagChange}
-      slackChannels={slackChannelsData.toString()}
-      isSlackEnabled={isSlackEnabled}
-      currentUser={currentUser}
-      isMobile={isMobile}
-    />
-  );
-};
-
-export default CommentEditorWrapper;

+ 385 - 0
packages/app/src/components/PageComment/CommentEditor.tsx

@@ -0,0 +1,385 @@
+import React, {
+  useCallback, useState, useRef, useEffect,
+} from 'react';
+
+import { UserPicture } from '@growi/ui';
+import {
+  Button,
+  TabContent, TabPane,
+} from 'reactstrap';
+import * as toastr from 'toastr';
+
+import AppContainer from '~/client/services/AppContainer';
+import EditorContainer from '~/client/services/EditorContainer';
+import PageContainer from '~/client/services/PageContainer';
+import GrowiRenderer from '~/client/util/GrowiRenderer';
+import { apiPostForm } from '~/client/util/apiv1-client';
+import { CustomWindow } from '~/interfaces/global';
+import { IInterceptorManager } from '~/interfaces/interceptor-manager';
+import { useSWRxPageComment } from '~/stores/comment';
+import {
+  useCurrentPagePath, useCurrentPageId, useCurrentUser, useRevisionId,
+} from '~/stores/context';
+import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
+import { useIsMobile } from '~/stores/ui';
+
+
+import { CustomNavTab } from '../CustomNavigation/CustomNav';
+import NotAvailableForGuest from '../NotAvailableForGuest';
+import Editor from '../PageEditor/Editor';
+import { SlackNotification } from '../SlackNotification';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import CommentPreview from './CommentPreview';
+
+
+const navTabMapping = {
+  comment_editor: {
+    Icon: () => <i className="icon-settings" />,
+    i18n: 'Write',
+    index: 0,
+  },
+  comment_preview: {
+    Icon: () => <i className="icon-settings" />,
+    i18n: 'Preview',
+    index: 1,
+  },
+};
+
+type PropsType = {
+  appContainer: AppContainer,
+
+  growiRenderer: GrowiRenderer,
+  isForNewComment?: boolean,
+  replyTo?: string,
+  currentCommentId?: string,
+  commentBody?: string,
+  commentCreator?: string,
+  onCancelButtonClicked?: () => void,
+  onCommentButtonClicked?: () => void,
+}
+
+type EditorRef = {
+  setValue: (value: string) => void,
+  insertText: (text: string) => void,
+  terminateUploadingState: () => void,
+}
+
+const CommentEditor = (props: PropsType): JSX.Element => {
+
+  const {
+    appContainer, growiRenderer, isForNewComment, replyTo,
+    currentCommentId, commentBody, commentCreator, onCancelButtonClicked, onCommentButtonClicked,
+  } = props;
+  const { data: currentUser } = useCurrentUser();
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: currentPageId } = useCurrentPageId();
+  const { update: updateComment, post: postComment } = useSWRxPageComment(currentPageId);
+  const { data: revisionId } = useRevisionId();
+  const { data: isMobile } = useIsMobile();
+  const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
+  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
+
+  const config = appContainer.getConfig();
+  const isUploadable = config.upload.image || config.upload.file;
+  const isUploadableFile = config.upload.file;
+  const isSlackConfigured = config.isSlackConfigured;
+
+  const [isReadyToUse, setIsReadyToUse] = useState(!isForNewComment);
+  const [comment, setComment] = useState(commentBody ?? '');
+  const [html, setHtml] = useState('');
+  const [activeTab, setActiveTab] = useState('comment_editor');
+  const [error, setError] = useState();
+  const [slackChannels, setSlackChannels] = useState(slackChannelsData?.toString());
+
+  const editorRef = useRef<EditorRef>(null);
+
+  const renderHtml = useCallback((markdown: string) => {
+    const context = {
+      markdown,
+      parsedHTML: '',
+    };
+
+    const interceptorManager: IInterceptorManager = (window as CustomWindow).interceptorManager;
+    interceptorManager.process('preRenderCommnetPreview', context)
+      .then(() => { return interceptorManager.process('prePreProcess', context) })
+      .then(() => {
+        context.markdown = growiRenderer.preProcess(context.markdown, context);
+      })
+      .then(() => { return interceptorManager.process('postPreProcess', context) })
+      .then(() => {
+        const parsedHTML = growiRenderer.process(context.markdown, context);
+        context.parsedHTML = parsedHTML;
+      })
+      .then(() => { return interceptorManager.process('prePostProcess', context) })
+      .then(() => {
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context);
+      })
+      .then(() => { return interceptorManager.process('postPostProcess', context) })
+      .then(() => { return interceptorManager.process('preRenderCommentPreviewHtml', context) })
+      .then(() => {
+        setHtml(context.parsedHTML);
+      })
+      // process interceptors for post rendering
+      .then(() => { return interceptorManager.process('postRenderCommentPreviewHtml', context) });
+  }, [growiRenderer]);
+
+  const handleSelect = useCallback((activeTab: string) => {
+    setActiveTab(activeTab);
+    renderHtml(comment);
+  }, [comment, renderHtml]);
+
+  useEffect(() => {
+    if (slackChannels === undefined) { return }
+    setSlackChannels(slackChannelsData?.toString());
+  }, [slackChannelsData, slackChannels]);
+
+  const initializeEditor = useCallback(() => {
+    setComment('');
+    setHtml('');
+    setActiveTab('comment_editor');
+    setError(undefined);
+    // reset value
+    if (editorRef.current == null) { return }
+    editorRef.current.setValue('');
+  }, []);
+
+  const cancelButtonClickedHandler = useCallback(() => {
+    // change state to not ready
+    // when this editor is for the new comment mode
+    if (isForNewComment) {
+      setIsReadyToUse(false);
+    }
+
+    if (onCancelButtonClicked != null) {
+      onCancelButtonClicked();
+    }
+  }, [isForNewComment, onCancelButtonClicked]);
+
+  const postCommentHandler = useCallback(async() => {
+    try {
+      if (currentCommentId != null) {
+        // update current comment
+        await updateComment(comment, revisionId, currentCommentId);
+      }
+      else {
+        // post new comment
+        const postCommentArgs = {
+          commentForm: {
+            comment,
+            revisionId,
+            replyTo,
+          },
+          slackNotificationForm: {
+            isSlackEnabled,
+            slackChannels,
+          },
+        };
+        await postComment(postCommentArgs);
+      }
+
+      initializeEditor();
+
+      if (onCommentButtonClicked != null) {
+        onCommentButtonClicked();
+      }
+    }
+    catch (err) {
+      const errorMessage = err.message || 'An unknown error occured when posting comment';
+      setError(errorMessage);
+    }
+  }, [
+    comment, currentCommentId, initializeEditor,
+    isSlackEnabled, onCommentButtonClicked, replyTo, slackChannels,
+    postComment, revisionId, updateComment,
+  ]);
+
+  const ctrlEnterHandler = useCallback((event) => {
+    if (event != null) {
+      event.preventDefault();
+    }
+
+    postCommentHandler();
+  }, [postCommentHandler]);
+
+  const apiErrorHandler = useCallback((error: Error) => {
+    toastr.error(error.message, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
+    });
+  }, []);
+
+  const uploadHandler = useCallback(async(file) => {
+
+    if (editorRef.current == null) { return }
+
+    const pagePath = currentPagePath;
+    const pageId = currentPageId;
+    const endpoint = '/attachments.add';
+    const formData = new FormData();
+    formData.append('file', file);
+    formData.append('path', pagePath ?? '');
+    formData.append('page_id', pageId ?? '');
+    try {
+      // TODO: typescriptize res
+      const res = await apiPostForm(endpoint, formData) as any;
+      const attachment = res.attachment;
+      const fileName = attachment.originalName;
+      let insertText = `[${fileName}](${attachment.filePathProxied})`;
+      // when image
+      if (attachment.fileFormat.startsWith('image/')) {
+        // modify to "![fileName](url)" syntax
+        insertText = `!${insertText}`;
+      }
+      editorRef.current.insertText(insertText);
+    }
+    catch (err) {
+      apiErrorHandler(err);
+    }
+    finally {
+      editorRef.current.terminateUploadingState();
+    }
+  }, [apiErrorHandler, currentPageId, currentPagePath]);
+
+  const getCommentHtml = useCallback(() => {
+    return (
+      <CommentPreview
+        html={html}
+      />
+    );
+  }, [html]);
+
+  const renderBeforeReady = useCallback((): JSX.Element => {
+    return (
+      <div className="text-center">
+        <NotAvailableForGuest>
+          <button
+            type="button"
+            className="btn btn-lg btn-link"
+            onClick={() => setIsReadyToUse(true)}
+          >
+            <i className="icon-bubble"></i> Add Comment
+          </button>
+        </NotAvailableForGuest>
+      </div>
+    );
+  }, []);
+
+  const renderReady = () => {
+
+    const commentPreview = getCommentHtml();
+
+    const errorMessage = <span className="text-danger text-right mr-2">{error}</span>;
+    const cancelButton = (
+      <Button outline color="danger" size="xs" className="btn btn-outline-danger rounded-pill" onClick={cancelButtonClickedHandler}>
+        Cancel
+      </Button>
+    );
+    const submitButton = (
+      <Button
+        outline
+        color="primary"
+        className="btn btn-outline-primary rounded-pill"
+        onClick={postCommentHandler}
+      >
+        Comment
+      </Button>
+    );
+
+    // TODO: typescriptize Editor
+    const AnyEditor = Editor as any;
+
+    return (
+      <>
+        <div className="comment-write">
+          <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={handleSelect} hideBorderBottom />
+          <TabContent activeTab={activeTab}>
+            <TabPane tabId="comment_editor">
+              <AnyEditor
+                ref={editorRef}
+                value={comment}
+                lineNumbers={false}
+                isMobile={isMobile}
+                isUploadable={isUploadable}
+                isUploadableFile={isUploadableFile}
+                onChange={setComment}
+                onUpload={uploadHandler}
+                onCtrlEnter={ctrlEnterHandler}
+                isComment
+              />
+              {/*
+                Note: <OptionsSelector /> is not optimized for ComentEditor in terms of responsive design.
+                See a review comment in https://github.com/weseek/growi/pull/3473
+              */}
+            </TabPane>
+            <TabPane tabId="comment_preview">
+              <div className="comment-form-preview">
+                {commentPreview}
+              </div>
+            </TabPane>
+          </TabContent>
+        </div>
+
+        <div className="comment-submit">
+          <div className="d-flex">
+            <span className="flex-grow-1" />
+            <span className="d-none d-sm-inline">{ errorMessage && errorMessage }</span>
+
+            { isSlackConfigured
+              && (
+                <div className="form-inline align-self-center mr-md-2">
+                  <SlackNotification
+                    isSlackEnabled
+                    slackChannels={slackChannelsData?.toString() ?? ''}
+                    onEnabledFlagChange={isSlackEnabled => mutateIsSlackEnabled(isSlackEnabled, false)}
+                    onChannelChange={setSlackChannels}
+                    id="idForComment"
+                  />
+                </div>
+              )
+            }
+            <div className="d-none d-sm-block">
+              <span className="mr-2">{cancelButton}</span><span>{submitButton}</span>
+            </div>
+          </div>
+          <div className="d-block d-sm-none mt-2">
+            <div className="d-flex justify-content-end">
+              { error && errorMessage }
+              <span className="mr-2">{cancelButton}</span><span>{submitButton}</span>
+            </div>
+          </div>
+        </div>
+      </>
+    );
+  };
+
+  return (
+    <div className="form page-comment-form">
+      <div className="comment-form">
+        <div className="comment-form-user">
+          <UserPicture user={currentUser} noLink noTooltip />
+        </div>
+        <div className="comment-form-main">
+          { isReadyToUse
+            ? renderReady()
+            : renderBeforeReady()
+          }
+        </div>
+      </div>
+    </div>
+  );
+
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const CommentEditorWrapper = withUnstatedContainers<unknown, Partial<PropsType>>(
+  CommentEditor, [AppContainer, PageContainer, EditorContainer],
+);
+
+export default CommentEditorWrapper;

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

@@ -2,6 +2,7 @@ import React, {
   useState, FC, useMemo, useEffect,
   useState, FC, useMemo, useEffect,
 } from 'react';
 } from 'react';
 
 
+import { pagePathUtils } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import {
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
@@ -20,7 +21,7 @@ import loggerFactory from '~/utils/logger';
 
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
 
-import { isTrashPage } from '^/../core/src/utils/page-path-utils';
+const { isTrashPage } = pagePathUtils;
 
 
 
 
 const logger = loggerFactory('growi:cli:PageDeleteModal');
 const logger = loggerFactory('growi:cli:PageDeleteModal');

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

@@ -411,8 +411,7 @@ const PageEditor = (props: Props): JSX.Element => {
       <div className="d-none d-lg-block page-editor-preview-container flex-grow-1 flex-basis-0 mw-0">
       <div className="d-none d-lg-block page-editor-preview-container flex-grow-1 flex-basis-0 mw-0">
         <Preview
         <Preview
           markdown={markdown}
           markdown={markdown}
-          // eslint-disable-next-line no-return-assign
-          inputRef={previewRef}
+          ref={previewRef}
           isMathJaxEnabled={isMathJaxEnabled}
           isMathJaxEnabled={isMathJaxEnabled}
           renderMathJaxOnInit={false}
           renderMathJaxOnInit={false}
           onScroll={offset => scrollEditorByPreviewScrollWithThrottle(offset)}
           onScroll={offset => scrollEditorByPreviewScrollWithThrottle(offset)}

+ 9 - 11
packages/app/src/components/PageEditor/Preview.tsx

@@ -1,5 +1,5 @@
 import React, {
 import React, {
-  useCallback, useEffect, useMemo, useState, SyntheticEvent,
+  useCallback, useEffect, useMemo, useState, SyntheticEvent, RefObject,
 } from 'react';
 } from 'react';
 
 
 
 
@@ -17,23 +17,21 @@ declare const interceptorManager: InterceptorManager;
 
 
 
 
 type Props = {
 type Props = {
-  appContainer: AppContainer,
   growiRenderer: GrowiRenderer,
   growiRenderer: GrowiRenderer,
   markdown?: string,
   markdown?: string,
   pagePath?: string,
   pagePath?: string,
-  inputRef?: React.RefObject<HTMLDivElement>,
   isMathJaxEnabled?: boolean,
   isMathJaxEnabled?: boolean,
   renderMathJaxOnInit?: boolean,
   renderMathJaxOnInit?: boolean,
   onScroll?: (scrollTop: number) => void,
   onScroll?: (scrollTop: number) => void,
 }
 }
 
 
+type UnstatedProps = Props & { appContainer: AppContainer };
 
 
-const Preview = (props: Props): JSX.Element => {
+const Preview = React.forwardRef((props: UnstatedProps, ref: RefObject<HTMLDivElement>): JSX.Element => {
 
 
   const {
   const {
-    appContainer, growiRenderer,
+    growiRenderer,
     markdown, pagePath,
     markdown, pagePath,
-    inputRef,
   } = props;
   } = props;
 
 
   const [html, setHtml] = useState('');
   const [html, setHtml] = useState('');
@@ -90,7 +88,7 @@ const Preview = (props: Props): JSX.Element => {
   return (
   return (
     <div
     <div
       className="page-editor-preview-body"
       className="page-editor-preview-body"
-      ref={inputRef}
+      ref={ref}
       onScroll={(event: SyntheticEvent<HTMLDivElement>) => {
       onScroll={(event: SyntheticEvent<HTMLDivElement>) => {
         if (props.onScroll != null) {
         if (props.onScroll != null) {
           props.onScroll(event.currentTarget.scrollTop);
           props.onScroll(event.currentTarget.scrollTop);
@@ -105,7 +103,7 @@ const Preview = (props: Props): JSX.Element => {
     </div>
     </div>
   );
   );
 
 
-};
+});
 
 
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
@@ -113,13 +111,13 @@ const Preview = (props: Props): JSX.Element => {
 const PreviewWrapper = withUnstatedContainers(Preview, [AppContainer]);
 const PreviewWrapper = withUnstatedContainers(Preview, [AppContainer]);
 
 
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-const PreviewWrapper2 = (props): JSX.Element => {
+const PreviewWrapper2 = React.forwardRef((props: Props, ref: RefObject<HTMLDivElement>): JSX.Element => {
   const { data: growiRenderer } = usePreviewRenderer();
   const { data: growiRenderer } = usePreviewRenderer();
   if (growiRenderer == null) {
   if (growiRenderer == null) {
     return <></>;
     return <></>;
   }
   }
 
 
-  return <PreviewWrapper growiRenderer={growiRenderer} {...props} />;
-};
+  return <PreviewWrapper ref={ref} growiRenderer={growiRenderer} {...props} />;
+});
 
 
 export default PreviewWrapper2;
 export default PreviewWrapper2;

+ 35 - 59
packages/app/src/components/PageHistory.jsx

@@ -1,66 +1,45 @@
-import React, { useCallback } from 'react';
-import PropTypes from 'prop-types';
-import loggerFactory from '~/utils/logger';
+import React, { useState, useEffect } from 'react';
 
 
-import { withUnstatedContainers } from './UnstatedUtils';
-import { toastError } from '~/client/util/apiNotification';
+import { useCurrentPageId } from '~/stores/context';
+import { useSWRxPageRevisions } from '~/stores/page';
+import loggerFactory from '~/utils/logger';
 
 
-import { withLoadingSppiner } from './SuspenseUtils';
 import PageRevisionTable from './PageHistory/PageRevisionTable';
 import PageRevisionTable from './PageHistory/PageRevisionTable';
-
-import PageHistroyContainer from '~/client/services/PageHistoryContainer';
 import PaginationWrapper from './PaginationWrapper';
 import PaginationWrapper from './PaginationWrapper';
 import RevisionComparer from './RevisionComparer/RevisionComparer';
 import RevisionComparer from './RevisionComparer/RevisionComparer';
-import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
 
 
 const logger = loggerFactory('growi:PageHistory');
 const logger = loggerFactory('growi:PageHistory');
 
 
-function PageHistory(props) {
-  const { pageHistoryContainer, revisionComparerContainer } = props;
-  const { getPreviousRevision } = pageHistoryContainer;
-  const {
-    activePage, totalPages, pagingLimit, revisions, diffOpened,
-  } = pageHistoryContainer.state;
+const PageHistory = () => {
+  const [activePage, setActivePage] = useState(1);
+  const { data: currentPageId } = useCurrentPageId();
+  const { data: revisionsData } = useSWRxPageRevisions(currentPageId, activePage, 10);
+  const [sourceRevision, setSourceRevision] = useState(null);
+  const [targetRevision, setTargetRevision] = useState(null);
 
 
-  const handlePage = useCallback(async(selectedPage) => {
-    try {
-      await props.pageHistoryContainer.retrieveRevisions(selectedPage);
-    }
-    catch (err) {
-      toastError(err);
-      props.pageHistoryContainer.setState({ errorMessage: err.message });
-      logger.error(err);
+  useEffect(() => {
+    if (revisionsData != null) {
+      setSourceRevision(revisionsData.revisions[0]);
+      setTargetRevision(revisionsData.revisions[0]);
     }
     }
-  }, [props.pageHistoryContainer]);
+  }, [revisionsData]);
 
 
-  if (pageHistoryContainer.state.errorMessage != null) {
+
+  const pagingLimit = 10;
+
+  if (revisionsData == null) {
     return (
     return (
-      <div className="my-5">
-        <div className="text-danger">{pageHistoryContainer.state.errorMessage}</div>
+      <div className="text-muted text-center">
+        <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
       </div>
       </div>
     );
     );
   }
   }
-
-  if (pageHistoryContainer.state.revisions === pageHistoryContainer.dummyRevisions) {
-    throw new Promise(async() => {
-      try {
-        await props.pageHistoryContainer.retrieveRevisions(1);
-        await props.revisionComparerContainer.initRevisions();
-      }
-      catch (err) {
-        toastError(err);
-        pageHistoryContainer.setState({ errorMessage: err.message });
-        logger.error(err);
-      }
-    });
-  }
-
   function pager() {
   function pager() {
     return (
     return (
       <PaginationWrapper
       <PaginationWrapper
         activePage={activePage}
         activePage={activePage}
-        changePage={handlePage}
-        totalItemsCount={totalPages}
+        changePage={setActivePage}
+        totalItemsCount={revisionsData.totalCounts}
         pagingLimit={pagingLimit}
         pagingLimit={pagingLimit}
         align="center"
         align="center"
       />
       />
@@ -70,26 +49,23 @@ function PageHistory(props) {
   return (
   return (
     <div className="revision-history" data-testid="page-history">
     <div className="revision-history" data-testid="page-history">
       <PageRevisionTable
       <PageRevisionTable
-        pageHistoryContainer={pageHistoryContainer}
-        revisionComparerContainer={revisionComparerContainer}
-        revisions={revisions}
-        diffOpened={diffOpened}
-        getPreviousRevision={getPreviousRevision}
+        revisions={revisionsData.revisions}
+        pagingLimit={pagingLimit}
+        sourceRevision={sourceRevision}
+        targetRevision={targetRevision}
+        onChangeSourceInvoked={setSourceRevision}
+        onChangeTargetInvoked={setTargetRevision}
       />
       />
       <div className="my-3">
       <div className="my-3">
         {pager()}
         {pager()}
       </div>
       </div>
-      <RevisionComparer />
+      <RevisionComparer
+        sourceRevision={sourceRevision}
+        targetRevision={targetRevision}
+        currentPageId={currentPageId}
+      />
     </div>
     </div>
   );
   );
-
-}
-
-const RenderPageHistoryWrapper = withUnstatedContainers(withLoadingSppiner(PageHistory), [PageHistroyContainer, RevisionComparerContainer]);
-
-PageHistory.propTypes = {
-  pageHistoryContainer: PropTypes.instanceOf(PageHistroyContainer).isRequired,
-  revisionComparerContainer: PropTypes.instanceOf(RevisionComparerContainer).isRequired,
 };
 };
 
 
-export default RenderPageHistoryWrapper;
+export default PageHistory;

+ 32 - 33
packages/app/src/components/PageHistory/PageRevisionTable.jsx

@@ -3,9 +3,6 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
-import PageHistroyContainer from '~/client/services/PageHistoryContainer';
-import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
-
 import Revision from './Revision';
 import Revision from './Revision';
 
 
 class PageRevisionTable extends React.Component {
 class PageRevisionTable extends React.Component {
@@ -17,21 +14,20 @@ class PageRevisionTable extends React.Component {
    * @param {boolean} hasDiff whether revision has difference to previousRevision
    * @param {boolean} hasDiff whether revision has difference to previousRevision
    * @param {boolean} isContiguousNodiff true if the current 'hasDiff' and one of previous row is both false
    * @param {boolean} isContiguousNodiff true if the current 'hasDiff' and one of previous row is both false
    */
    */
-  renderRow(revision, previousRevision, hasDiff, isContiguousNodiff) {
-    const { revisionComparerContainer, t } = this.props;
-    const { latestRevision, oldestRevision } = this.props.pageHistoryContainer.state;
+  renderRow(revision, previousRevision, latestRevision, isOldestRevision, hasDiff) {
+    const {
+      t, sourceRevision, targetRevision, onChangeSourceInvoked, onChangeTargetInvoked,
+    } = this.props;
     const revisionId = revision._id;
     const revisionId = revision._id;
-    const revisionDiffOpened = this.props.diffOpened[revisionId] || false;
-    const { sourceRevision, targetRevision } = revisionComparerContainer.state;
 
 
     const handleCompareLatestRevisionButton = () => {
     const handleCompareLatestRevisionButton = () => {
-      revisionComparerContainer.setState({ sourceRevision: revision });
-      revisionComparerContainer.setState({ targetRevision: latestRevision });
+      onChangeSourceInvoked(revision);
+      onChangeTargetInvoked(latestRevision);
     };
     };
 
 
     const handleComparePreviousRevisionButton = () => {
     const handleComparePreviousRevisionButton = () => {
-      revisionComparerContainer.setState({ sourceRevision: previousRevision });
-      revisionComparerContainer.setState({ targetRevision: revision });
+      onChangeSourceInvoked(previousRevision);
+      onChangeTargetInvoked(revision);
     };
     };
 
 
     return (
     return (
@@ -42,7 +38,6 @@ class PageRevisionTable extends React.Component {
               t={this.props.t}
               t={this.props.t}
               revision={revision}
               revision={revision}
               isLatestRevision={revision === latestRevision}
               isLatestRevision={revision === latestRevision}
-              revisionDiffOpened={revisionDiffOpened}
               hasDiff={hasDiff}
               hasDiff={hasDiff}
               key={`revision-history-rev-${revisionId}`}
               key={`revision-history-rev-${revisionId}`}
             />
             />
@@ -60,7 +55,7 @@ class PageRevisionTable extends React.Component {
                     type="button"
                     type="button"
                     className="btn btn-outline-secondary btn-sm"
                     className="btn btn-outline-secondary btn-sm"
                     onClick={handleComparePreviousRevisionButton}
                     onClick={handleComparePreviousRevisionButton}
-                    disabled={revision === oldestRevision}
+                    disabled={isOldestRevision}
                   >
                   >
                     {t('page_history.compare_previous')}
                     {t('page_history.compare_previous')}
                   </button>
                   </button>
@@ -70,34 +65,34 @@ class PageRevisionTable extends React.Component {
           </div>
           </div>
         </td>
         </td>
         <td className="col-1">
         <td className="col-1">
-          {(hasDiff || revision._id === sourceRevision?._id) && (
+          {(hasDiff || revisionId === sourceRevision?._id) && (
             <div className="custom-control custom-radio custom-control-inline mr-0">
             <div className="custom-control custom-radio custom-control-inline mr-0">
               <input
               <input
                 type="radio"
                 type="radio"
                 className="custom-control-input"
                 className="custom-control-input"
-                id={`compareSource-${revision._id}`}
+                id={`compareSource-${revisionId}`}
                 name="compareSource"
                 name="compareSource"
-                value={revision._id}
-                checked={revision._id === sourceRevision?._id}
-                onChange={() => revisionComparerContainer.setState({ sourceRevision: revision })}
+                value={revisionId}
+                checked={revisionId === sourceRevision?._id}
+                onChange={() => onChangeSourceInvoked(revision)}
               />
               />
-              <label className="custom-control-label" htmlFor={`compareSource-${revision._id}`} />
+              <label className="custom-control-label" htmlFor={`compareSource-${revisionId}`} />
             </div>
             </div>
           )}
           )}
         </td>
         </td>
         <td className="col-2">
         <td className="col-2">
-          {(hasDiff || revision._id === targetRevision?._id) && (
+          {(hasDiff || revisionId === targetRevision?._id) && (
             <div className="custom-control custom-radio custom-control-inline mr-0">
             <div className="custom-control custom-radio custom-control-inline mr-0">
               <input
               <input
                 type="radio"
                 type="radio"
                 className="custom-control-input"
                 className="custom-control-input"
-                id={`compareTarget-${revision._id}`}
+                id={`compareTarget-${revisionId}`}
                 name="compareTarget"
                 name="compareTarget"
-                value={revision._id}
-                checked={revision._id === targetRevision?._id}
-                onChange={() => revisionComparerContainer.setState({ targetRevision: revision })}
+                value={revisionId}
+                checked={revisionId === targetRevision?._id}
+                onChange={() => onChangeTargetInvoked(revision)}
               />
               />
-              <label className="custom-control-label" htmlFor={`compareTarget-${revision._id}`} />
+              <label className="custom-control-label" htmlFor={`compareTarget-${revisionId}`} />
             </div>
             </div>
           )}
           )}
         </td>
         </td>
@@ -106,16 +101,18 @@ class PageRevisionTable extends React.Component {
   }
   }
 
 
   render() {
   render() {
-    const { t, pageHistoryContainer } = this.props;
+    const { t, pagingLimit } = this.props;
 
 
     const revisions = this.props.revisions;
     const revisions = this.props.revisions;
     const revisionCount = this.props.revisions.length;
     const revisionCount = this.props.revisions.length;
+    const latestRevision = revisions[0];
+    const oldestRevision = revisions[revisions.length - 1];
 
 
     let hasDiffPrev;
     let hasDiffPrev;
 
 
     const revisionList = this.props.revisions.map((revision, idx) => {
     const revisionList = this.props.revisions.map((revision, idx) => {
       // Returns null because the last revision is for the bottom diff display
       // Returns null because the last revision is for the bottom diff display
-      if (idx === pageHistoryContainer.state.pagingLimit) {
+      if (idx === pagingLimit) {
         return null;
         return null;
       }
       }
 
 
@@ -127,13 +124,13 @@ class PageRevisionTable extends React.Component {
         previousRevision = revision; // if it is the first revision, show full text as diff text
         previousRevision = revision; // if it is the first revision, show full text as diff text
       }
       }
 
 
+      const isOldestRevision = revision === oldestRevision;
 
 
       const hasDiff = revision.hasDiffToPrev !== false; // set 'true' if undefined for backward compatibility
       const hasDiff = revision.hasDiffToPrev !== false; // set 'true' if undefined for backward compatibility
-      const isContiguousNodiff = !hasDiff && !hasDiffPrev;
 
 
       hasDiffPrev = hasDiff;
       hasDiffPrev = hasDiff;
 
 
-      return this.renderRow(revision, previousRevision, hasDiff, isContiguousNodiff);
+      return this.renderRow(revision, previousRevision, latestRevision, isOldestRevision, hasDiff);
     });
     });
 
 
     return (
     return (
@@ -156,11 +153,13 @@ class PageRevisionTable extends React.Component {
 
 
 PageRevisionTable.propTypes = {
 PageRevisionTable.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
-  pageHistoryContainer: PropTypes.instanceOf(PageHistroyContainer).isRequired,
-  revisionComparerContainer: PropTypes.instanceOf(RevisionComparerContainer).isRequired,
 
 
   revisions: PropTypes.array,
   revisions: PropTypes.array,
-  diffOpened: PropTypes.object,
+  pagingLimit: PropTypes.number,
+  sourceRevision: PropTypes.instanceOf(Object),
+  targetRevision: PropTypes.instanceOf(Object),
+  onChangeSourceInvoked: PropTypes.func.isRequired,
+  onChangeTargetInvoked: PropTypes.func.isRequired,
 };
 };
 
 
 const PageRevisionTableWrapperFC = (props) => {
 const PageRevisionTableWrapperFC = (props) => {

+ 2 - 2
packages/app/src/components/PageHistory/Revision.jsx

@@ -1,7 +1,8 @@
 import React from 'react';
 import React from 'react';
-import PropTypes from 'prop-types';
 
 
 import { UserPicture } from '@growi/ui';
 import { UserPicture } from '@growi/ui';
+import PropTypes from 'prop-types';
+
 import UserDate from '../User/UserDate';
 import UserDate from '../User/UserDate';
 import Username from '../User/Username';
 import Username from '../User/Username';
 
 
@@ -83,6 +84,5 @@ Revision.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
   revision: PropTypes.object,
   revision: PropTypes.object,
   isLatestRevision: PropTypes.bool.isRequired,
   isLatestRevision: PropTypes.bool.isRequired,
-  revisionDiffOpened: PropTypes.bool.isRequired,
   hasDiff: PropTypes.bool.isRequired,
   hasDiff: PropTypes.bool.isRequired,
 };
 };

+ 24 - 26
packages/app/src/components/RevisionComparer/RevisionComparer.jsx

@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useState, useEffect } from 'react';
 
 
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
@@ -8,11 +8,9 @@ import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-
-import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
+import { useCurrentPagePath } from '~/stores/context';
 
 
 import RevisionDiff from '../PageHistory/RevisionDiff';
 import RevisionDiff from '../PageHistory/RevisionDiff';
-import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 
 
 const { encodeSpaces } = pagePathUtils;
 const { encodeSpaces } = pagePathUtils;
@@ -29,12 +27,13 @@ const DropdownItemContents = ({ title, contents }) => (
 
 
 const RevisionComparer = (props) => {
 const RevisionComparer = (props) => {
 
 
-  const [dropdownOpen, setDropdownOpen] = useState(false);
-
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { revisionComparerContainer } = props;
-
-  const { path, pageId } = revisionComparerContainer.pageContainer.state;
+  const { data: currentPagePath } = useCurrentPagePath();
+  const [dropdownOpen, setDropdownOpen] = useState(false);
+  const {
+    sourceRevision, targetRevision,
+    currentPageId,
+  } = props;
 
 
   function toggleDropdown() {
   function toggleDropdown() {
     setDropdownOpen(!dropdownOpen);
     setDropdownOpen(!dropdownOpen);
@@ -42,7 +41,6 @@ const RevisionComparer = (props) => {
 
 
   const generateURL = (pathName) => {
   const generateURL = (pathName) => {
     const { origin } = window.location;
     const { origin } = window.location;
-    const { sourceRevision, targetRevision } = revisionComparerContainer.state;
 
 
     const url = new URL(pathName, origin);
     const url = new URL(pathName, origin);
 
 
@@ -55,13 +53,17 @@ const RevisionComparer = (props) => {
 
 
   };
   };
 
 
-  const { sourceRevision, targetRevision } = revisionComparerContainer.state;
-
+  let isNodiff;
   if (sourceRevision == null || targetRevision == null) {
   if (sourceRevision == null || targetRevision == null) {
-    return null;
+    isNodiff = true;
+  }
+  else {
+    isNodiff = sourceRevision._id === targetRevision._id;
   }
   }
 
 
-  const isNodiff = sourceRevision._id === targetRevision._id;
+  if (currentPageId == null || currentPagePath == null) {
+    return <>{ t('not_found_page.page_not_exist')}</>;
+  }
 
 
   return (
   return (
     <div className="revision-compare">
     <div className="revision-compare">
@@ -80,15 +82,15 @@ const RevisionComparer = (props) => {
           </DropdownToggle>
           </DropdownToggle>
           <DropdownMenu positionFixed right modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
           <DropdownMenu positionFixed right modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
             {/* Page path URL */}
             {/* Page path URL */}
-            <CopyToClipboard text={generateURL(path)}>
+            <CopyToClipboard text={generateURL(currentPagePath)}>
               <DropdownItem className="px-3">
               <DropdownItem className="px-3">
-                <DropdownItemContents title={t('copy_to_clipboard.Page URL')} contents={generateURL(path)} />
+                <DropdownItemContents title={t('copy_to_clipboard.Page URL')} contents={generateURL(currentPagePath)} />
               </DropdownItem>
               </DropdownItem>
             </CopyToClipboard>
             </CopyToClipboard>
             {/* Permanent Link URL */}
             {/* Permanent Link URL */}
-            <CopyToClipboard text={generateURL(pageId)}>
+            <CopyToClipboard text={generateURL(currentPageId)}>
               <DropdownItem className="px-3">
               <DropdownItem className="px-3">
-                <DropdownItemContents title={t('copy_to_clipboard.Permanent link')} contents={generateURL(pageId)} />
+                <DropdownItemContents title={t('copy_to_clipboard.Permanent link')} contents={generateURL(currentPageId)} />
               </DropdownItem>
               </DropdownItem>
             </CopyToClipboard>
             </CopyToClipboard>
             <DropdownItem divider className="my-0"></DropdownItem>
             <DropdownItem divider className="my-0"></DropdownItem>
@@ -115,13 +117,9 @@ const RevisionComparer = (props) => {
 };
 };
 
 
 RevisionComparer.propTypes = {
 RevisionComparer.propTypes = {
-  revisionComparerContainer: PropTypes.instanceOf(RevisionComparerContainer).isRequired,
-  revisions: PropTypes.array,
+  sourceRevision: PropTypes.instanceOf(Object),
+  targetRevision: PropTypes.instanceOf(Object),
+  currentPageId: PropTypes.string,
 };
 };
 
 
-/**
- * Wrapper component for using unstated
- */
-const RevisionComparerWrapper = withUnstatedContainers(RevisionComparer, [RevisionComparerContainer]);
-
-export default RevisionComparerWrapper;
+export default RevisionComparer;

+ 3 - 5
packages/app/src/components/SubscribeButton.tsx

@@ -18,9 +18,6 @@ const SubscribeButton: FC<Props> = (props: Props) => {
 
 
   const isSubscribing = status === SubscriptionStatusType.SUBSCRIBE;
   const isSubscribing = status === SubscriptionStatusType.SUBSCRIBE;
 
 
-  const buttonClass = `${isSubscribing ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`;
-  const iconClass = isSubscribing === false ? 'fa fa-eye-slash' : 'fa fa-eye';
-
   const getTooltipMessage = useCallback(() => {
   const getTooltipMessage = useCallback(() => {
     if (isGuestUser) {
     if (isGuestUser) {
       return 'Not available for guest';
       return 'Not available for guest';
@@ -38,9 +35,10 @@ const SubscribeButton: FC<Props> = (props: Props) => {
         type="button"
         type="button"
         id="subscribe-button"
         id="subscribe-button"
         onClick={props.onClick}
         onClick={props.onClick}
-        className={`btn btn-subscribe border-0 ${buttonClass}`}
+        className={`shadow-none btn btn-subscribe border-0
+          ${isSubscribing ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
       >
-        <i className={iconClass}></i>
+        <i className={`fa ${isSubscribing ? 'fa-bell' : 'fa-bell-slash-o'}`}></i>
       </button>
       </button>
 
 
       <UncontrolledTooltip placement="top" target="subscribe-button" fade={false}>
       <UncontrolledTooltip placement="top" target="subscribe-button" fade={false}>

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

@@ -24,7 +24,7 @@ const SeenUserInfo: FC<Props> = (props: Props) => {
 
 
   return (
   return (
     <div className="grw-seen-user-info">
     <div className="grw-seen-user-info">
-      <button type="button" id="btn-seen-user" className="btn btn-seen-user border-0">
+      <button type="button" id="btn-seen-user" className="shadow-none btn btn-seen-user border-0">
         <span className="mr-1 footstamp-icon">
         <span className="mr-1 footstamp-icon">
           <FootstampIcon />
           <FootstampIcon />
         </span>
         </span>

+ 470 - 9
packages/app/src/interfaces/activity.ts

@@ -1,10 +1,41 @@
+import { Ref } from './common';
+import { HasObjectId } from './has-object-id';
+import { IUser } from './user';
+
 // Model
 // Model
 const MODEL_PAGE = 'Page';
 const MODEL_PAGE = 'Page';
 const MODEL_COMMENT = 'Comment';
 const MODEL_COMMENT = 'Comment';
 
 
 // Action
 // Action
+const ACTION_UNSETTLED = 'UNSETTLED';
+const ACTION_USER_REGISTRATION_SUCCESS = 'USER_REGISTRATION_SUCCESS';
+const ACTION_USER_LOGIN_WITH_LOCAL = 'USER_LOGIN_WITH_LOCAL';
+const ACTION_USER_LOGIN_WITH_LDAP = 'USER_LOGIN_WITH_LDAP';
+const ACTION_USER_LOGIN_WITH_GOOGLE = 'USER_LOGIN_WITH_GOOGLE';
+const ACTION_USER_LOGIN_WITH_GITHUB = 'USER_LOGIN_WITH_GITHUB';
+const ACTION_USER_LOGIN_WITH_TWITTER = 'USER_LOGIN_WITH_TWITTER';
+const ACTION_USER_LOGIN_WITH_OIDC = 'USER_LOGIN_WITH_OIDC';
+const ACTION_USER_LOGIN_WITH_SAML = 'USER_LOGIN_WITH_SAML';
+const ACTION_USER_LOGIN_WITH_BASIC = 'USER_LOGIN_WITH_BASIC';
+const ACTION_USER_LOGIN_FAILURE = 'USER_LOGIN_FAILURE';
+const ACTION_USER_LOGOUT = 'USER_LOGOUT';
+const ACTION_USER_PERSONAL_SETTINGS_UPDATE = 'USER_PERSONAL_SETTINGS_UPDATE';
+const ACTION_USER_IMAGE_TYPE_UPDATE = 'USER_IMAGE_TYPE_UPDATE';
+const ACTION_USER_LDAP_ACCOUNT_ASSOCIATE = 'USER_LDAP_ACCOUNT_ASSOCIATE';
+const ACTION_USER_LDAP_ACCOUNT_DISCONNECT = 'USER_LDAP_ACCOUNT_DISCONNECT';
+const ACTION_USER_PASSWORD_UPDATE = 'USER_PASSWORD_UPDATE';
+const ACTION_USER_API_TOKEN_UPDATE = 'USER_API_TOKEN_UPDATE';
+const ACTION_USER_EDITOR_SETTINGS_UPDATE = 'USER_EDITOR_SETTINGS_UPDATE';
+const ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE = 'USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE';
+const ACTION_PAGE_VIEW = 'PAGE_VIEW';
+const ACTION_PAGE_USER_HOME_VIEW = 'PAGE_USER_HOME_VIEW';
+const ACTION_PAGE_NOT_FOUND = 'PAGE_NOT_FOUND';
+const ACTION_PAGE_FORBIDDEN = 'PAGE_FORBIDDEN';
+const ACTION_PAGE_NOT_CREATABLE = 'PAGE_NOT_CREATABLE';
 const ACTION_PAGE_LIKE = 'PAGE_LIKE';
 const ACTION_PAGE_LIKE = 'PAGE_LIKE';
+const ACTION_PAGE_UNLIKE = 'PAGE_UNLIKE';
 const ACTION_PAGE_BOOKMARK = 'PAGE_BOOKMARK';
 const ACTION_PAGE_BOOKMARK = 'PAGE_BOOKMARK';
+const ACTION_PAGE_UNBOOKMARK = 'PAGE_UNBOOKMARK';
 const ACTION_PAGE_CREATE = 'PAGE_CREATE';
 const ACTION_PAGE_CREATE = 'PAGE_CREATE';
 const ACTION_PAGE_UPDATE = 'PAGE_UPDATE';
 const ACTION_PAGE_UPDATE = 'PAGE_UPDATE';
 const ACTION_PAGE_RENAME = 'PAGE_RENAME';
 const ACTION_PAGE_RENAME = 'PAGE_RENAME';
@@ -12,21 +43,151 @@ const ACTION_PAGE_DUPLICATE = 'PAGE_DUPLICATE';
 const ACTION_PAGE_DELETE = 'PAGE_DELETE';
 const ACTION_PAGE_DELETE = 'PAGE_DELETE';
 const ACTION_PAGE_DELETE_COMPLETELY = 'PAGE_DELETE_COMPLETELY';
 const ACTION_PAGE_DELETE_COMPLETELY = 'PAGE_DELETE_COMPLETELY';
 const ACTION_PAGE_REVERT = 'PAGE_REVERT';
 const ACTION_PAGE_REVERT = 'PAGE_REVERT';
+const ACTION_PAGE_EMPTY_TRASH = 'PAGE_EMPTY_TRASH';
+const ACTION_PAGE_SUBSCRIBE = 'PAGE_SUBSCRIBE';
+const ACTION_PAGE_UNSUBSCRIBE = 'PAGE_UNSUBSCRIBE';
+const ACTION_PAGE_EXPORT = 'PAGE_EXPORT';
+const ACTION_TAG_UPDATE = 'TAG_UPDATE';
+const ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN = 'IN_APP_NOTIFICATION_ALL_STATUSES_OPEN';
 const ACTION_COMMENT_CREATE = 'COMMENT_CREATE';
 const ACTION_COMMENT_CREATE = 'COMMENT_CREATE';
 const ACTION_COMMENT_UPDATE = 'COMMENT_UPDATE';
 const ACTION_COMMENT_UPDATE = 'COMMENT_UPDATE';
+const ACTION_COMMENT_REMOVE = 'COMMENT_REMOVE';
+const ACTION_SHARE_LINK_CREATE = 'SHARE_LINK_CREATE';
+const ACTION_SHARE_LINK_DELETE = 'SHARE_LINK_DELETE';
+const ACTION_SHARE_LINK_DELETE_BY_PAGE = 'SHARE_LINK_DELETE_BY_PAGE';
+const ACTION_SHARE_LINK_ALL_DELETE = 'SHARE_LINK_ALL_DELETE';
+const ACTION_SHARE_LINK_PAGE_VIEW = 'SHARE_LINK_PAGE_VIEW';
+const ACTION_SHARE_LINK_EXPIRED_PAGE_VIEW = 'SHARE_LINK_EXPIRED_PAGE_VIEW';
+const ACTION_SHARE_LINK_NOT_FOUND = 'SHARE_LINK_NOT_FOUND';
+const ACTION_ATTACHMENT_ADD = 'ATTACHMENT_ADD';
+const ACTION_ATTACHMENT_REMOVE = 'ATTACHMENT_REMOVE';
+const ACTION_ATTACHMENT_DOWNLOAD = 'ACTION_ATTACHMENT_DOWNLOAD';
+const ACTION_SEARCH_PAGE = 'SEARCH_PAGE';
+const ACTION_SEARCH_PAGE_VIEW = 'SEARCH_PAGE_VIEW';
+const ACTION_ADMIN_APP_SETTINGS_UPDATE = 'ADMIN_APP_SETTING_UPDATE';
+const ACTION_ADMIN_SITE_URL_UPDATE = 'ADMIN_SITE_URL_UPDATE';
+const ACTION_ADMIN_MAIL_SMTP_UPDATE = 'ADMIN_MAIL_SMTP_UPDATE';
+const ACTION_ADMIN_MAIL_SES_UPDATE = 'ADMIN_MAIL_SES_UPDATE';
+const ACTION_ADMIN_MAIL_TEST_SUBMIT = 'ADMIN_MAIL_TEST_SUBMIT ';
+const ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE = 'ADMIN_FILE_UPLOAD_CONFIG_UPDATE';
+const ACTION_ADMIN_PLUGIN_UPDATE = 'ADMIN_PLUGIN_UPDATE';
+const ACTION_ADMIN_MAINTENANCEMODE_ENABLED = 'ADMIN_MAINTENANCEMODE_ENABLED';
+const ACTION_ADMIN_MAINTENANCEMODE_DISABLED = 'ADMIN_MAINTENANCEMODE_DISABLED';
+const ACTION_ADMIN_SECURITY_SETTINGS_UPDATE = 'ADMIN_SECURITY_SETTINGS_UPDATE';
+const ACTION_ADMIN_PERMIT_SHARE_LINK = 'ADMIN_PERMIT_SHARE_LINK';
+const ACTION_ADMIN_REJECT_SHARE_LINK = 'ADMIN_REJECT_SHARE_LINK';
+const ACTION_ADMIN_AUTH_ID_PASS_ENABLED = 'ADMIN_AUTH_ID_PASS_ENABLED';
+const ACTION_ADMIN_AUTH_ID_PASS_DISABLED = 'ADMIN_AUTH_ID_PASS_DISABLED';
+const ACTION_ADMIN_AUTH_ID_PASS_UPDATE = 'ADMIN_AUTH_ID_PASS_UPDATE';
+const ACTION_ADMIN_AUTH_LDAP_ENABLED = 'ADMIN_AUTH_LDAP_ENABLED';
+const ACTION_ADMIN_AUTH_LDAP_DISABLED = 'ADMIN_AUTH_LDAP_DISABLED';
+const ACTION_ADMIN_AUTH_LDAP_UPDATE = 'ADMIN_AUTH_LDAP_UPDATE';
+const ACTION_ADMIN_AUTH_SAML_ENABLED = 'ADMIN_AUTH_SAML_ENABLED';
+const ACTION_ADMIN_AUTH_SAML_DISABLED = 'ADMIN_AUTH_SAML_DISABLED';
+const ACTION_ADMIN_AUTH_SAML_UPDATE = 'ADMIN_AUTH_SAML_UPDATE';
+const ACTION_ADMIN_AUTH_OIDC_ENABLED = 'ADMIN_AUTH_OIDC_ENABLED';
+const ACTION_ADMIN_AUTH_OIDC_DISABLED = 'ADMIN_AUTH_OIDC_DISABLED';
+const ACTION_ADMIN_AUTH_OIDC_UPDATE = 'ADMIN_AUTH_OIDC_UPDATE';
+const ACTION_ADMIN_AUTH_BASIC_ENABLED = 'ADMIN_AUTH_BASIC_ENABLED';
+const ACTION_ADMIN_AUTH_BASIC_DISABLED = 'ADMIN_AUTH_BASIC_DISABLED';
+const ACTION_ADMIN_AUTH_BASIC_UPDATE = 'ADMIN_AUTH_BASIC_UPDATE';
+const ACTION_ADMIN_AUTH_GOOGLE_ENABLED = 'ADMIN_AUTH_GOOGLE_ENABLED';
+const ACTION_ADMIN_AUTH_GOOGLE_DISABLED = 'ADMIN_AUTH_GOOGLE_DISABLED';
+const ACTION_ADMIN_AUTH_GOOGLE_UPDATE = 'ADMIN_AUTH_GOOGLE_UPDATE';
+const ACTION_ADMIN_AUTH_GITHUB_ENABLED = 'ADMIN_AUTH_GITHUB_ENABLED';
+const ACTION_ADMIN_AUTH_GITHUB_DISABLED = 'ADMIN_AUTH_GITHUB_DISABLED';
+const ACTION_ADMIN_AUTH_GITHUB_UPDATE = 'ADMIN_AUTH_GITHUB_UPDATE';
+const ACTION_ADMIN_AUTH_TWITTER_ENABLED = 'ADMIN_AUTH_TWITTER_ENABLED';
+const ACTION_ADMIN_AUTH_TWITTER_DISABLED = 'ADMIN_AUTH_TWITTER_DISABLED';
+const ACTION_ADMIN_AUTH_TWITTER_UPDATE = 'ADMIN_AUTH_TWITTER_UPDATE';
+const ACTION_ADMIN_MARKDOWN_LINE_BREAK_UPDATE = 'ADMIN_MARKDOWN_LINE_BREAK_UPDATE';
+const ACTION_ADMIN_MARKDOWN_INDENT_UPDATE = 'ADMIN_MARKDOWN_INDENT_UPDATE';
+const ACTION_ADMIN_MARKDOWN_PRESENTATION_UPDATE = 'ADMIN_MARKDOWN_PRESENTATION_UPDATE';
+const ACTION_ADMIN_MARKDOWN_XSS_UPDATE = 'ADMIN_MARKDOWN_XSS_UPDATE';
+const ACTION_ADMIN_LAYOUT_UPDATE = 'ADMIN_LAYOUT_UPDATE';
+const ACTION_ADMIN_THEME_UPDATE = 'ADMIN_THEME_UPDATE';
+const ACTION_ADMIN_FUNCTION_UPDATE = 'ADMIN_FUNCTION_UPDATE';
+const ACTION_ADMIN_CODE_HIGHLIGHT_UPDATE = 'ADMIN_CODE_HIGHLIGHT_UPDATE';
+const ACTION_ADMIN_CUSTOM_TITLE_UPDATE = 'ADMIN_CUSTOM_TITLE_UPDATE';
+const ACTION_ADMIN_CUSTOM_HTML_HEADER_UPDATE = 'ADMIN_CUSTOM_HTML_HEADER_UPDATE';
+const ACTION_ADMIN_CUSTOM_CSS_UPDATE = 'ADMIN_CUSTOM_CSS_UPDATE';
+const ACTION_ADMIN_CUSTOM_SCRIPT_UPDATE = 'ADMIN_CUSTOM_SCRIPT_UPDATE';
+const ACTION_ADMIN_ARCHIVE_DATA_UPLOAD = 'ADMIN_ARCHIVE_DATA_UPLOAD';
+const ACTION_ADMIN_ARCHIVE_DATA_CREATE = 'ADMIN_ARCHIVE_DATA_CREATE';
+const ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_ADD = 'ADMIN_USER_NOTIFICATION_SETTINGS_ADD';
+const ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_DELETE = 'ADMIN_USER_NOTIFICATION_SETTINGS_DELETE';
+const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD = 'ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD';
+const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE = 'ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE';
+const ACTION_ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE = 'ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE';
+const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED = 'ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED';
+const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED = 'ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED';
+const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE = 'ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE';
+const ACTION_ADMIN_SLACK_WORKSPACE_CREATE = 'ADMIN_SLACK_WORKSPACE_CREATE';
+const ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE = 'ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE';
+const ACTION_ADMIN_USERS_INVITE = 'ADMIN_USERS_INVITE';
+const ACTION_ADMIN_USER_GROUP_CREATE = 'ADMIN_USER_GROUP_CREATE';
+const ACTION_ADMIN_USER_GROUP_UPDATE = 'ADMIN_USER_GROUP_UPDATE';
+const ACTION_ADMIN_USER_GROUP_DELETE = 'ADMIN_USER_GROUP_DELETE';
+const ACTION_ADMIN_USER_GROUP_ADD_USER = 'ADMIN_USER_GROUP_ADD_USER';
+const ACTION_ADMIN_SEARCH_INDICES_NORMALIZE = 'ADMIN_SEARCH_INDICES_NORMALIZE';
+const ACTION_ADMIN_SEARCH_INDICES_REBUILD = 'ADMIN_SEARCH_INDICES_REBUILD';
+const ACTION_ADMIN_UPLOADED_GROWI_DATA_DISCARDED = 'ADMIN_UPLOADED_GROWI_DATA_DISCARDED';
+const ACTION_ADMIN_ESA_DATA_UPDATED = 'ADMIN_ESA_DATA_UPDATED';
+const ACTION_ADMIN_CONNECTION_TEST_OF_ESA_DATA = 'ADMIN_CONNECTION_TEST_OF_ESA_DATA';
+const ACTION_ADMIN_QIITA_DATA_UPDATED = 'ADMIN_QIITA_DATA_UPDATED';
+const ACTION_ADMIN_CONNECTION_TEST_OF_QIITA_DATA = 'ADMIN_CONNECTION_TEST_OF_QIITA_DATA';
 
 
 
 
-export const SUPPORTED_TARGET_MODEL_TYPE = {
+export const SupportedTargetModel = {
   MODEL_PAGE,
   MODEL_PAGE,
 } as const;
 } as const;
 
 
-export const SUPPORTED_EVENT_MODEL_TYPE = {
+export const SupportedEventModel = {
   MODEL_COMMENT,
   MODEL_COMMENT,
 } as const;
 } as const;
 
 
-export const SUPPORTED_ACTION_TYPE = {
+export const SupportedActionCategory = {
+  PAGE: 'Page',
+  COMMENT: 'Comment',
+  TAG: 'Tag',
+  ATTACHMENT: 'Attachment',
+  SHARE_LINK: 'ShareLink',
+  IN_APP_NOTIFICATION: 'InAppNotification',
+  SEARCH: 'Search',
+  USER: 'User',
+  ADMIN: 'Admin',
+} as const;
+
+export const SupportedAction = {
+  ACTION_UNSETTLED,
+  ACTION_USER_REGISTRATION_SUCCESS,
+  ACTION_USER_LOGIN_WITH_LOCAL,
+  ACTION_USER_LOGIN_WITH_LDAP,
+  ACTION_USER_LOGIN_WITH_GOOGLE,
+  ACTION_USER_LOGIN_WITH_GITHUB,
+  ACTION_USER_LOGIN_WITH_TWITTER,
+  ACTION_USER_LOGIN_WITH_OIDC,
+  ACTION_USER_LOGIN_WITH_SAML,
+  ACTION_USER_LOGIN_WITH_BASIC,
+  ACTION_USER_LOGIN_FAILURE,
+  ACTION_USER_LOGOUT,
+  ACTION_USER_PERSONAL_SETTINGS_UPDATE,
+  ACTION_USER_IMAGE_TYPE_UPDATE,
+  ACTION_USER_LDAP_ACCOUNT_ASSOCIATE,
+  ACTION_USER_LDAP_ACCOUNT_DISCONNECT,
+  ACTION_USER_PASSWORD_UPDATE,
+  ACTION_USER_API_TOKEN_UPDATE,
+  ACTION_USER_EDITOR_SETTINGS_UPDATE,
+  ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE,
+  ACTION_PAGE_VIEW,
+  ACTION_PAGE_USER_HOME_VIEW,
+  ACTION_PAGE_FORBIDDEN,
+  ACTION_PAGE_NOT_FOUND,
+  ACTION_PAGE_NOT_CREATABLE,
   ACTION_PAGE_LIKE,
   ACTION_PAGE_LIKE,
+  ACTION_PAGE_UNLIKE,
   ACTION_PAGE_BOOKMARK,
   ACTION_PAGE_BOOKMARK,
+  ACTION_PAGE_UNBOOKMARK,
   ACTION_PAGE_CREATE,
   ACTION_PAGE_CREATE,
   ACTION_PAGE_UPDATE,
   ACTION_PAGE_UPDATE,
   ACTION_PAGE_RENAME,
   ACTION_PAGE_RENAME,
@@ -34,15 +195,315 @@ export const SUPPORTED_ACTION_TYPE = {
   ACTION_PAGE_DELETE,
   ACTION_PAGE_DELETE,
   ACTION_PAGE_DELETE_COMPLETELY,
   ACTION_PAGE_DELETE_COMPLETELY,
   ACTION_PAGE_REVERT,
   ACTION_PAGE_REVERT,
+  ACTION_PAGE_EMPTY_TRASH,
+  ACTION_PAGE_SUBSCRIBE,
+  ACTION_PAGE_UNSUBSCRIBE,
+  ACTION_PAGE_EXPORT,
+  ACTION_TAG_UPDATE,
+  ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN,
   ACTION_COMMENT_CREATE,
   ACTION_COMMENT_CREATE,
   ACTION_COMMENT_UPDATE,
   ACTION_COMMENT_UPDATE,
+  ACTION_COMMENT_REMOVE,
+  ACTION_SHARE_LINK_CREATE,
+  ACTION_SHARE_LINK_DELETE,
+  ACTION_SHARE_LINK_DELETE_BY_PAGE,
+  ACTION_SHARE_LINK_ALL_DELETE,
+  ACTION_SHARE_LINK_PAGE_VIEW,
+  ACTION_SHARE_LINK_EXPIRED_PAGE_VIEW,
+  ACTION_SHARE_LINK_NOT_FOUND,
+  ACTION_ATTACHMENT_ADD,
+  ACTION_ATTACHMENT_REMOVE,
+  ACTION_ATTACHMENT_DOWNLOAD,
+  ACTION_SEARCH_PAGE,
+  ACTION_SEARCH_PAGE_VIEW,
+  ACTION_ADMIN_APP_SETTINGS_UPDATE,
+  ACTION_ADMIN_SITE_URL_UPDATE,
+  ACTION_ADMIN_MAIL_SMTP_UPDATE,
+  ACTION_ADMIN_MAIL_SES_UPDATE,
+  ACTION_ADMIN_MAIL_TEST_SUBMIT,
+  ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE,
+  ACTION_ADMIN_PLUGIN_UPDATE,
+  ACTION_ADMIN_MAINTENANCEMODE_ENABLED,
+  ACTION_ADMIN_MAINTENANCEMODE_DISABLED,
+  ACTION_ADMIN_SECURITY_SETTINGS_UPDATE,
+  ACTION_ADMIN_PERMIT_SHARE_LINK,
+  ACTION_ADMIN_REJECT_SHARE_LINK,
+  ACTION_ADMIN_AUTH_ID_PASS_ENABLED,
+  ACTION_ADMIN_AUTH_ID_PASS_DISABLED,
+  ACTION_ADMIN_AUTH_ID_PASS_UPDATE,
+  ACTION_ADMIN_AUTH_LDAP_ENABLED,
+  ACTION_ADMIN_AUTH_LDAP_DISABLED,
+  ACTION_ADMIN_AUTH_LDAP_UPDATE,
+  ACTION_ADMIN_AUTH_SAML_ENABLED,
+  ACTION_ADMIN_AUTH_SAML_DISABLED,
+  ACTION_ADMIN_AUTH_SAML_UPDATE,
+  ACTION_ADMIN_AUTH_OIDC_ENABLED,
+  ACTION_ADMIN_AUTH_OIDC_DISABLED,
+  ACTION_ADMIN_AUTH_OIDC_UPDATE,
+  ACTION_ADMIN_AUTH_BASIC_ENABLED,
+  ACTION_ADMIN_AUTH_BASIC_DISABLED,
+  ACTION_ADMIN_AUTH_BASIC_UPDATE,
+  ACTION_ADMIN_AUTH_GOOGLE_ENABLED,
+  ACTION_ADMIN_AUTH_GOOGLE_DISABLED,
+  ACTION_ADMIN_AUTH_GOOGLE_UPDATE,
+  ACTION_ADMIN_AUTH_GITHUB_ENABLED,
+  ACTION_ADMIN_AUTH_GITHUB_DISABLED,
+  ACTION_ADMIN_AUTH_GITHUB_UPDATE,
+  ACTION_ADMIN_AUTH_TWITTER_ENABLED,
+  ACTION_ADMIN_AUTH_TWITTER_DISABLED,
+  ACTION_ADMIN_AUTH_TWITTER_UPDATE,
+  ACTION_ADMIN_MARKDOWN_LINE_BREAK_UPDATE,
+  ACTION_ADMIN_MARKDOWN_INDENT_UPDATE,
+  ACTION_ADMIN_MARKDOWN_PRESENTATION_UPDATE,
+  ACTION_ADMIN_MARKDOWN_XSS_UPDATE,
+  ACTION_ADMIN_LAYOUT_UPDATE,
+  ACTION_ADMIN_THEME_UPDATE,
+  ACTION_ADMIN_FUNCTION_UPDATE,
+  ACTION_ADMIN_CODE_HIGHLIGHT_UPDATE,
+  ACTION_ADMIN_CUSTOM_TITLE_UPDATE,
+  ACTION_ADMIN_CUSTOM_HTML_HEADER_UPDATE,
+  ACTION_ADMIN_CUSTOM_CSS_UPDATE,
+  ACTION_ADMIN_CUSTOM_SCRIPT_UPDATE,
+  ACTION_ADMIN_ARCHIVE_DATA_UPLOAD,
+  ACTION_ADMIN_ARCHIVE_DATA_CREATE,
+  ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_ADD,
+  ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_DELETE,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE,
+  ACTION_ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE,
+  ACTION_ADMIN_SLACK_WORKSPACE_CREATE,
+  ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE,
+  ACTION_ADMIN_USERS_INVITE,
+  ACTION_ADMIN_USER_GROUP_CREATE,
+  ACTION_ADMIN_USER_GROUP_UPDATE,
+  ACTION_ADMIN_USER_GROUP_DELETE,
+  ACTION_ADMIN_USER_GROUP_ADD_USER,
+  ACTION_ADMIN_SEARCH_INDICES_NORMALIZE,
+  ACTION_ADMIN_SEARCH_INDICES_REBUILD,
+  ACTION_ADMIN_UPLOADED_GROWI_DATA_DISCARDED,
+  ACTION_ADMIN_ESA_DATA_UPDATED,
+  ACTION_ADMIN_CONNECTION_TEST_OF_ESA_DATA,
+  ACTION_ADMIN_QIITA_DATA_UPDATED,
+  ACTION_ADMIN_CONNECTION_TEST_OF_QIITA_DATA,
 } as const;
 } as const;
 
 
+// Action required for notification
+export const EssentialActionGroup = {
+  ACTION_PAGE_LIKE,
+  ACTION_PAGE_BOOKMARK,
+  ACTION_PAGE_UPDATE,
+  ACTION_PAGE_RENAME,
+  ACTION_PAGE_DUPLICATE,
+  ACTION_PAGE_DELETE,
+  ACTION_PAGE_DELETE_COMPLETELY,
+  ACTION_PAGE_REVERT,
+  ACTION_COMMENT_CREATE,
+} as const;
+
+export const ActionGroupSize = {
+  Small: 'SMALL',
+  Medium: 'MEDIUM',
+  Large: 'LARGE',
+} as const;
+
+export const SmallActionGroup = {
+  ACTION_USER_LOGIN_WITH_LOCAL,
+  ACTION_USER_LOGIN_WITH_LDAP,
+  ACTION_USER_LOGIN_WITH_GOOGLE,
+  ACTION_USER_LOGIN_WITH_GITHUB,
+  ACTION_USER_LOGIN_WITH_TWITTER,
+  ACTION_USER_LOGIN_WITH_OIDC,
+  ACTION_USER_LOGIN_WITH_SAML,
+  ACTION_USER_LOGIN_WITH_BASIC,
+  ACTION_USER_LOGIN_FAILURE,
+  ACTION_USER_LOGOUT,
+  ACTION_PAGE_CREATE,
+  ACTION_PAGE_DELETE,
+} as const;
+
+// SmallActionGroup + Action by all General Users - PAGE_VIEW
+export const MediumActionGroup = {
+  ...SmallActionGroup,
+  ACTION_USER_REGISTRATION_SUCCESS,
+  ACTION_USER_PERSONAL_SETTINGS_UPDATE,
+  ACTION_USER_IMAGE_TYPE_UPDATE,
+  ACTION_USER_LDAP_ACCOUNT_ASSOCIATE,
+  ACTION_USER_LDAP_ACCOUNT_DISCONNECT,
+  ACTION_USER_PASSWORD_UPDATE,
+  ACTION_USER_API_TOKEN_UPDATE,
+  ACTION_USER_EDITOR_SETTINGS_UPDATE,
+  ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE,
+  ACTION_PAGE_LIKE,
+  ACTION_PAGE_UNLIKE,
+  ACTION_PAGE_BOOKMARK,
+  ACTION_PAGE_UNBOOKMARK,
+  ACTION_PAGE_CREATE,
+  ACTION_PAGE_UPDATE,
+  ACTION_PAGE_RENAME,
+  ACTION_PAGE_DUPLICATE,
+  ACTION_PAGE_DELETE,
+  ACTION_PAGE_DELETE_COMPLETELY,
+  ACTION_PAGE_REVERT,
+  ACTION_PAGE_EMPTY_TRASH,
+  ACTION_PAGE_SUBSCRIBE,
+  ACTION_PAGE_UNSUBSCRIBE,
+  ACTION_PAGE_EXPORT,
+  ACTION_TAG_UPDATE,
+  ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN,
+  ACTION_COMMENT_CREATE,
+  ACTION_COMMENT_UPDATE,
+  ACTION_COMMENT_REMOVE,
+  ACTION_SHARE_LINK_CREATE,
+  ACTION_SHARE_LINK_DELETE,
+  ACTION_SHARE_LINK_DELETE_BY_PAGE,
+  ACTION_ATTACHMENT_ADD,
+  ACTION_ATTACHMENT_REMOVE,
+  ACTION_ATTACHMENT_DOWNLOAD,
+  ACTION_SEARCH_PAGE,
+  ACTION_SEARCH_PAGE_VIEW,
+} as const;
+
+// MediumActionGroup + All Actions by Admin Users - PAGE_VIEW
+export const LargeActionGroup = {
+  ...MediumActionGroup,
+  ACTION_SHARE_LINK_ALL_DELETE,
+  ACTION_ADMIN_APP_SETTINGS_UPDATE,
+  ACTION_ADMIN_SITE_URL_UPDATE,
+  ACTION_ADMIN_MAIL_SMTP_UPDATE,
+  ACTION_ADMIN_MAIL_SES_UPDATE,
+  ACTION_ADMIN_MAIL_TEST_SUBMIT,
+  ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE,
+  ACTION_ADMIN_PLUGIN_UPDATE,
+  ACTION_ADMIN_MAINTENANCEMODE_ENABLED,
+  ACTION_ADMIN_MAINTENANCEMODE_DISABLED,
+  ACTION_ADMIN_SECURITY_SETTINGS_UPDATE,
+  ACTION_ADMIN_PERMIT_SHARE_LINK,
+  ACTION_ADMIN_REJECT_SHARE_LINK,
+  ACTION_ADMIN_AUTH_ID_PASS_ENABLED,
+  ACTION_ADMIN_AUTH_ID_PASS_DISABLED,
+  ACTION_ADMIN_AUTH_ID_PASS_UPDATE,
+  ACTION_ADMIN_AUTH_LDAP_ENABLED,
+  ACTION_ADMIN_AUTH_LDAP_DISABLED,
+  ACTION_ADMIN_AUTH_LDAP_UPDATE,
+  ACTION_ADMIN_AUTH_SAML_ENABLED,
+  ACTION_ADMIN_AUTH_SAML_DISABLED,
+  ACTION_ADMIN_AUTH_SAML_UPDATE,
+  ACTION_ADMIN_AUTH_OIDC_ENABLED,
+  ACTION_ADMIN_AUTH_OIDC_DISABLED,
+  ACTION_ADMIN_AUTH_OIDC_UPDATE,
+  ACTION_ADMIN_AUTH_BASIC_ENABLED,
+  ACTION_ADMIN_AUTH_BASIC_DISABLED,
+  ACTION_ADMIN_AUTH_BASIC_UPDATE,
+  ACTION_ADMIN_AUTH_GOOGLE_ENABLED,
+  ACTION_ADMIN_AUTH_GOOGLE_DISABLED,
+  ACTION_ADMIN_AUTH_GOOGLE_UPDATE,
+  ACTION_ADMIN_AUTH_GITHUB_ENABLED,
+  ACTION_ADMIN_AUTH_GITHUB_DISABLED,
+  ACTION_ADMIN_AUTH_GITHUB_UPDATE,
+  ACTION_ADMIN_AUTH_TWITTER_ENABLED,
+  ACTION_ADMIN_AUTH_TWITTER_DISABLED,
+  ACTION_ADMIN_AUTH_TWITTER_UPDATE,
+  ACTION_ADMIN_MARKDOWN_LINE_BREAK_UPDATE,
+  ACTION_ADMIN_MARKDOWN_INDENT_UPDATE,
+  ACTION_ADMIN_MARKDOWN_PRESENTATION_UPDATE,
+  ACTION_ADMIN_MARKDOWN_XSS_UPDATE,
+  ACTION_ADMIN_LAYOUT_UPDATE,
+  ACTION_ADMIN_THEME_UPDATE,
+  ACTION_ADMIN_FUNCTION_UPDATE,
+  ACTION_ADMIN_CODE_HIGHLIGHT_UPDATE,
+  ACTION_ADMIN_CUSTOM_TITLE_UPDATE,
+  ACTION_ADMIN_CUSTOM_HTML_HEADER_UPDATE,
+  ACTION_ADMIN_CUSTOM_CSS_UPDATE,
+  ACTION_ADMIN_CUSTOM_SCRIPT_UPDATE,
+  ACTION_ADMIN_ARCHIVE_DATA_UPLOAD,
+  ACTION_ADMIN_ARCHIVE_DATA_CREATE,
+  ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_ADD,
+  ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_DELETE,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE,
+  ACTION_ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE,
+  ACTION_ADMIN_SLACK_WORKSPACE_CREATE,
+  ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE,
+  ACTION_ADMIN_USERS_INVITE,
+  ACTION_ADMIN_USER_GROUP_CREATE,
+  ACTION_ADMIN_USER_GROUP_UPDATE,
+  ACTION_ADMIN_USER_GROUP_DELETE,
+  ACTION_ADMIN_USER_GROUP_ADD_USER,
+  ACTION_ADMIN_SEARCH_INDICES_NORMALIZE,
+  ACTION_ADMIN_SEARCH_INDICES_REBUILD,
+  ACTION_ADMIN_UPLOADED_GROWI_DATA_DISCARDED,
+  ACTION_ADMIN_ESA_DATA_UPDATED,
+  ACTION_ADMIN_CONNECTION_TEST_OF_ESA_DATA,
+  ACTION_ADMIN_QIITA_DATA_UPDATED,
+  ACTION_ADMIN_CONNECTION_TEST_OF_QIITA_DATA,
+} as const;
+
+
+/*
+ * Array
+ */
+export const AllSupportedTargetModels = Object.values(SupportedTargetModel);
+export const AllSupportedEventModels = Object.values(SupportedEventModel);
+export const AllSupportedActions = Object.values(SupportedAction);
+export const AllEssentialActions = Object.values(EssentialActionGroup);
+export const AllSmallGroupActions = Object.values(SmallActionGroup);
+export const AllMediumGroupActions = Object.values(MediumActionGroup);
+export const AllLargeGroupActions = Object.values(LargeActionGroup);
+
+// Action categories(for SelectActionDropdown.tsx)
+const pageRegExp = new RegExp(`^${SupportedActionCategory.PAGE.toUpperCase()}_`);
+const commentRegExp = new RegExp(`^${SupportedActionCategory.COMMENT.toUpperCase()}_`);
+const tagRegExp = new RegExp(`^${SupportedActionCategory.TAG.toUpperCase()}_`);
+const attachmentRegExp = RegExp(`^${SupportedActionCategory.ATTACHMENT.toUpperCase()}_`);
+const shareLinkRegExp = RegExp(`^${SupportedActionCategory.SHARE_LINK.toUpperCase()}_`);
+const inAppNotificationRegExp = RegExp(`^${SupportedActionCategory.IN_APP_NOTIFICATION.toUpperCase()}_`);
+const searchRegExp = RegExp(`^${SupportedActionCategory.SEARCH.toUpperCase()}_`);
+const userRegExp = new RegExp(`^${SupportedActionCategory.USER.toUpperCase()}_`);
+const adminRegExp = new RegExp(`^${SupportedActionCategory.ADMIN.toUpperCase()}_`);
+
+export const PageActions = AllSupportedActions.filter(action => action.match(pageRegExp));
+export const CommentActions = AllSupportedActions.filter(action => action.match(commentRegExp));
+export const TagActions = AllSupportedActions.filter(action => action.match(tagRegExp));
+export const AttachmentActions = AllSupportedActions.filter(action => action.match(attachmentRegExp));
+export const ShareLinkActions = AllSupportedActions.filter(action => action.match(shareLinkRegExp));
+export const InAppNotificationActions = AllSupportedActions.filter(action => action.match(inAppNotificationRegExp));
+export const SearchActions = AllSupportedActions.filter(action => action.match(searchRegExp));
+export const UserActions = AllSupportedActions.filter(action => action.match(userRegExp));
+export const AdminActions = AllSupportedActions.filter(action => action.match(adminRegExp));
+
+/*
+ * Type
+ */
+export type SupportedTargetModelType = typeof SupportedTargetModel[keyof typeof SupportedTargetModel];
+export type SupportedEventModelType = typeof SupportedEventModel[keyof typeof SupportedEventModel];
+export type SupportedActionType = typeof SupportedAction[keyof typeof SupportedAction];
+export type SupportedActionCategoryType = typeof SupportedActionCategory[keyof typeof SupportedActionCategory]
+
+export type ISnapshot = Partial<Pick<IUser, 'username'>>
+
+export type IActivity = {
+  user?: Ref<IUser>
+  ip?: string
+  endpoint?: string
+  targetModel?: SupportedTargetModelType
+  target?: string
+  eventModel?: SupportedEventModelType
+  event?: string
+  action: SupportedActionType
+  createdAt: Date
+  snapshot?: ISnapshot
+}
 
 
-export const AllSupportedTargetModelType = Object.values(SUPPORTED_TARGET_MODEL_TYPE);
-export const AllSupportedEventModelType = Object.values(SUPPORTED_EVENT_MODEL_TYPE);
-export const AllSupportedActionType = Object.values(SUPPORTED_ACTION_TYPE);
+export type IActivityHasId = IActivity & HasObjectId;
 
 
-// type supportedTargetModelType = typeof SUPPORTED_TARGET_MODEL_NAMES[keyof typeof SUPPORTED_TARGET_MODEL_NAMES];
-// type supportedEventModelType = typeof SUPPORTED_EVENT_MODEL_NAMES[keyof typeof SUPPORTED_EVENT_MODEL_NAMES];
-// type supportedActionType = typeof SUPPORTED_ACTION_NAMES[keyof typeof SUPPORTED_ACTION_NAMES];
+export type ISearchFilter = {
+  usernames?: string[]
+  dates?: {startDate: string | null, endDate: string | null}
+  actions?: SupportedActionType[]
+}

+ 14 - 2
packages/app/src/interfaces/comment.ts

@@ -1,8 +1,8 @@
 import { Nullable, Ref } from './common';
 import { Nullable, Ref } from './common';
+import { HasObjectId } from './has-object-id';
 import { IPage } from './page';
 import { IPage } from './page';
-import { IUser } from './user';
 import { IRevision } from './revision';
 import { IRevision } from './revision';
-import { HasObjectId } from './has-object-id';
+import { IUser } from './user';
 
 
 export type IComment = {
 export type IComment = {
   comment: string;
   comment: string;
@@ -16,5 +16,17 @@ export type IComment = {
   creator: IUser,
   creator: IUser,
 };
 };
 
 
+export interface ICommentPostArgs {
+  commentForm: {
+    comment: string,
+    revisionId: string,
+    replyTo: string|undefined
+  },
+  slackNotificationForm: {
+    isSlackEnabled: boolean|undefined,
+    slackChannels: string|undefined,
+  },
+}
+
 export type ICommentHasId = IComment & HasObjectId;
 export type ICommentHasId = IComment & HasObjectId;
 export type ICommentHasIdList = ICommentHasId[];
 export type ICommentHasIdList = ICommentHasId[];

+ 10 - 0
packages/app/src/interfaces/external-account.ts

@@ -0,0 +1,10 @@
+import { Ref } from '~/interfaces/common';
+import { IUser } from '~/interfaces/user';
+
+
+export type IExternalAccount<ID = string> = {
+  _id: ID,
+  providerType: string,
+  accountId: string,
+  user: Ref<IUser>,
+}

+ 11 - 1
packages/app/src/interfaces/global.ts

@@ -1,3 +1,13 @@
+import EventEmitter from 'events';
+
 import Xss from '~/services/xss';
 import Xss from '~/services/xss';
 
 
-export type CustomWindow = Window & typeof globalThis & { xss: Xss };
+import { IGraphViewer } from './graph-viewer';
+import { IInterceptorManager } from './interceptor-manager';
+
+export type CustomWindow = Window
+                         & typeof globalThis
+                         & { xss: Xss }
+                         & { interceptorManager: IInterceptorManager }
+                         & { globalEmitter: EventEmitter }
+                         & { GraphViewer: IGraphViewer };

+ 3 - 0
packages/app/src/interfaces/graph-viewer.ts

@@ -0,0 +1,3 @@
+export interface IGraphViewer {
+  createViewerForElement: (Element) => void,
+}

+ 15 - 0
packages/app/src/interfaces/interceptor-manager.ts

@@ -0,0 +1,15 @@
+interface BasicInterceptor {
+  getId: () => string,
+  isInterceptWhen: (contextName: string) => boolean,
+  isProcessableParallel: () => boolean,
+  process: (contextName: string, args: any) => Promise<any>
+}
+
+export interface IInterceptorManager {
+  interceptorAndOrders: {interceptor: BasicInterceptor, order: number}[],
+  interceptors: BasicInterceptor[],
+  addInterceptor: (inerceptor: BasicInterceptor, order: number) => void,
+  addInterceptors: (inerceptors: BasicInterceptor[], order: number) => void,
+  process: (contextName: string, args: any) => Promise<void>,
+  doProcess: (inerceptor: BasicInterceptor, contextName: string, args: any) => Promise<void>
+}

+ 13 - 0
packages/app/src/interfaces/mongoose-utils.ts

@@ -0,0 +1,13 @@
+export interface PaginateResult<T> {
+  docs: T[];
+  hasNextPage: boolean;
+  hasPrevPage: boolean;
+  limit: number;
+  nextPage: number | null;
+  offset: number;
+  page: number;
+  pagingCounter: number;
+  prevPage: number | null;
+  totalDocs: number;
+  totalPages: number;
+}

+ 0 - 5
packages/app/src/interfaces/page-listing-results.ts

@@ -23,11 +23,6 @@ export interface TargetAndAncestors {
 }
 }
 
 
 
 
-export interface IsNotFoundPermalink {
-  isNotFoundPermalink: boolean
-}
-
-
 export interface V5MigrationStatus {
 export interface V5MigrationStatus {
   isV5Compatible : boolean,
   isV5Compatible : boolean,
   migratablePagesCount: number
   migratablePagesCount: number

+ 5 - 0
packages/app/src/interfaces/revision.ts

@@ -8,6 +8,11 @@ export type IRevision = {
   updatedAt: Date,
   updatedAt: Date,
 }
 }
 
 
+export type IRevisionsForPagination = {
+  revisions: IRevision[], // revisions in one pagination
+  totalCounts: number // total counts
+}
+
 export type IRevisionOnConflict = {
 export type IRevisionOnConflict = {
   revisionId: string,
   revisionId: string,
   revisionBody: string,
   revisionBody: string,

+ 10 - 6
packages/app/src/interfaces/user.ts

@@ -3,15 +3,19 @@ import { Ref } from './common';
 import { HasObjectId } from './has-object-id';
 import { HasObjectId } from './has-object-id';
 
 
 export type IUser = {
 export type IUser = {
-  name: string;
-  username: string;
-  email: string;
-  password: string;
+  name: string,
+  username: string,
+  email: string,
+  password: string,
   image?: string, // for backward conpatibility
   image?: string, // for backward conpatibility
   imageAttachment?: Ref<IAttachment>,
   imageAttachment?: Ref<IAttachment>,
-  imageUrlCached: string;
+  imageUrlCached: string,
   isGravatarEnabled: boolean,
   isGravatarEnabled: boolean,
-  admin: boolean;
+  admin: boolean,
+  apiToken?: string,
+  isEmailPublished: boolean,
+  lang: string,
+  slackMemberId?: string,
 }
 }
 
 
 export type IUserGroupRelation = {
 export type IUserGroupRelation = {

+ 1 - 2
packages/app/src/next-i18next.config.ts

@@ -3,8 +3,7 @@ import path from 'path';
 export const
 export const
   i18n = {
   i18n = {
     defaultLocale: 'en_US',
     defaultLocale: 'en_US',
-    locales: ['ja_JP', 'zh_CN'],
+    locales: ['en_US', 'ja_JP', 'zh_CN'],
   };
   };
 export const defaultNS = 'translation';
 export const defaultNS = 'translation';
 export const localePath = path.resolve('./public/static/locales');
 export const localePath = path.resolve('./public/static/locales');
-export const allLocales = [i18n.defaultLocale].concat(i18n.locales);

+ 2 - 2
packages/app/src/server/crowi/dev.js

@@ -1,6 +1,6 @@
 import path from 'path';
 import path from 'path';
 
 
-import { allLocales } from '~/next-i18next.config';
+import { i18n } from '~/next-i18next.config';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 const onHeaders = require('on-headers');
 const onHeaders = require('on-headers');
@@ -42,7 +42,7 @@ class CrowiDev {
    */
    */
   requireForAutoReloadServer() {
   requireForAutoReloadServer() {
     // load all json files for live reloading
     // load all json files for live reloading
-    allLocales
+    i18n.locales
       .forEach((localeId) => {
       .forEach((localeId) => {
         require(path.join(this.crowi.publicDir, 'static/locales', localeId, 'translation.json'));
         require(path.join(this.crowi.publicDir, 'static/locales', localeId, 'translation.json'));
       });
       });

+ 2 - 9
packages/app/src/server/crowi/express-init.js

@@ -1,6 +1,6 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
-import { allLocales, localePath } from '~/next-i18next.config';
+import { i18n, localePath } from '~/next-i18next.config';
 
 
 module.exports = function(crowi, app) {
 module.exports = function(crowi, app) {
   const debug = require('debug')('growi:crowi:express-init');
   const debug = require('debug')('growi:crowi:express-init');
@@ -42,7 +42,7 @@ module.exports = function(crowi, app) {
     .init({
     .init({
       // debug: true,
       // debug: true,
       fallbackLng: ['en_US'],
       fallbackLng: ['en_US'],
-      whitelist: allLocales,
+      whitelist: i18n.locales,
       backend: {
       backend: {
         loadPath: `${localePath}/{{lng}}/translation.json`,
         loadPath: `${localePath}/{{lng}}/translation.json`,
       },
       },
@@ -79,13 +79,6 @@ module.exports = function(crowi, app) {
     res.locals.baseUrl = crowi.appService.getSiteUrl();
     res.locals.baseUrl = crowi.appService.getSiteUrl();
     res.locals.env = env;
     res.locals.env = env;
     res.locals.now = now;
     res.locals.now = now;
-    res.locals.consts = {
-      pageGrants: Page.getGrantLabels(),
-      userStatus: User.getUserStatusLabels(),
-      language:   allLocales,
-      restrictGuestMode: crowi.aclService.getRestrictGuestModeLabels(),
-      registrationMode: crowi.aclService.getRegistrationModeLabels(),
-    };
     res.locals.local_config = Config.getLocalconfig(crowi); // config for browser context
     res.locals.local_config = Config.getLocalconfig(crowi); // config for browser context
 
 
     next();
     next();

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

@@ -90,6 +90,7 @@ function Crowi() {
   this.events = {
   this.events = {
     user: new (require('../events/user'))(this),
     user: new (require('../events/user'))(this),
     page: new (require('../events/page'))(this),
     page: new (require('../events/page'))(this),
+    activity: new (require('../events/activity'))(this),
     bookmark: new (require('../events/bookmark'))(this),
     bookmark: new (require('../events/bookmark'))(this),
     comment: new (require('../events/comment'))(this),
     comment: new (require('../events/comment'))(this),
     tag: new (require('../events/tag'))(this),
     tag: new (require('../events/tag'))(this),
@@ -710,6 +711,7 @@ Crowi.prototype.setupActivityService = async function() {
   const ActivityService = require('../service/activity');
   const ActivityService = require('../service/activity');
   if (this.activityService == null) {
   if (this.activityService == null) {
     this.activityService = new ActivityService(this);
     this.activityService = new ActivityService(this);
+    await this.activityService.createTtlIndex();
   }
   }
 };
 };
 
 

+ 10 - 13
packages/app/src/server/events/activity.ts

@@ -1,20 +1,17 @@
-import { EventEmitter } from 'events';
-import loggerFactory from '../../utils/logger';
-
-const logger = loggerFactory('growi:events:activity');
+import loggerFactory from '~/utils/logger';
 
 
+import Crowi from '../crowi';
 
 
-class ActivityEvent extends EventEmitter {
+const logger = loggerFactory('growi:events:activity');
 
 
-  onRemove(action: string, activity: any): void {
-    logger.info('onRemove activity event fired');
-  }
+const events = require('events');
+const util = require('util');
 
 
-  onCreate(action: string, activity: any): void {
-    logger.info('onCreate activity event fired');
-  }
+function ActivityEvent(crowi: Crowi) {
+  this.crowi = crowi;
 
 
+  events.EventEmitter.call(this);
 }
 }
+util.inherits(ActivityEvent, events.EventEmitter);
 
 
-const instance = new ActivityEvent();
-export default instance;
+module.exports = ActivityEvent;

+ 2 - 2
packages/app/src/server/events/comment.ts

@@ -2,9 +2,9 @@ import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('growi:events:comment');
 const logger = loggerFactory('growi:events:comment');
 
 
+const events = require('events');
 const util = require('util');
 const util = require('util');
 
 
-const events = require('events');
 
 
 function CommentEvent(crowi) {
 function CommentEvent(crowi) {
   this.crowi = crowi;
   this.crowi = crowi;
@@ -20,7 +20,7 @@ CommentEvent.prototype.onUpdate = function(comment) {
   logger.info('onUpdate comment event fired');
   logger.info('onUpdate comment event fired');
 };
 };
 CommentEvent.prototype.onDelete = function(comment) {
 CommentEvent.prototype.onDelete = function(comment) {
-  logger.info('onRemove comment event fired');
+  logger.info('onDelete comment event fired');
 };
 };
 
 
 module.exports = CommentEvent;
 module.exports = CommentEvent;

+ 5 - 0
packages/app/src/server/interfaces/search.ts

@@ -36,6 +36,11 @@ export type SearchableData<T = Partial<QueryTerms>> = {
   terms: T
   terms: T
 }
 }
 
 
+export type UpdateOrInsertPagesOpts = {
+  shouldEmitProgress?: boolean
+  invokeGarbageCollection?: boolean
+}
+
 // Terms Key types
 // Terms Key types
 export type AllTermsKey = keyof QueryTerms;
 export type AllTermsKey = keyof QueryTerms;
 export type UnavailableTermsKey<K extends AllTermsKey> = Exclude<AllTermsKey, K>;
 export type UnavailableTermsKey<K extends AllTermsKey> = Exclude<AllTermsKey, K>;

+ 40 - 0
packages/app/src/server/middlewares/add-activity.ts

@@ -0,0 +1,40 @@
+import { NextFunction, Request, Response } from 'express';
+
+import { SupportedAction } from '~/interfaces/activity';
+import { IUserHasId } from '~/interfaces/user';
+import Activity from '~/server/models/activity';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:middlewares:add-activity');
+
+interface AuthorizedRequest extends Request {
+  user?: IUserHasId
+}
+
+export const generateAddActivityMiddleware = crowi => async(req: AuthorizedRequest, res: Response, next: NextFunction): Promise<void> => {
+  if (req.method === 'GET') {
+    logger.warn('This middleware is not available for GET requests');
+    return next();
+  }
+
+  const parameter = {
+    ip:  req.ip,
+    endpoint: req.originalUrl,
+    action: SupportedAction.ACTION_UNSETTLED,
+    user: req.user?._id,
+    snapshot: {
+      username: req.user?.username,
+    },
+  };
+
+  try {
+    const activity = await Activity.createByParameters(parameter);
+    res.locals.activity = activity;
+  }
+  catch (err) {
+    logger.error('Create activity failed', err);
+  }
+
+  return next();
+};

+ 12 - 12
packages/app/src/server/middlewares/login-required.js

@@ -12,18 +12,6 @@ module.exports = (crowi, isGuestAllowed = false, fallback = null) => {
 
 
   return function(req, res, next) {
   return function(req, res, next) {
 
 
-    // check the route config and ACL
-    if (isGuestAllowed && crowi.aclService.isGuestAllowedToRead()) {
-      logger.debug('Allowed to read: ', req.path);
-      return next();
-    }
-
-    // check the page is shared
-    if (isGuestAllowed && req.isSharedPage) {
-      logger.debug('Target page is shared page');
-      return next();
-    }
-
     const User = crowi.model('User');
     const User = crowi.model('User');
 
 
     // check the user logged in
     // check the user logged in
@@ -43,6 +31,18 @@ module.exports = (crowi, isGuestAllowed = false, fallback = null) => {
       }
       }
     }
     }
 
 
+    // check the route config and ACL
+    if (isGuestAllowed && crowi.aclService.isGuestAllowedToRead()) {
+      logger.debug('Allowed to read: ', req.path);
+      return next();
+    }
+
+    // check the page is shared
+    if (isGuestAllowed && req.isSharedPage) {
+      logger.debug('Target page is shared page');
+      return next();
+    }
+
     // is api path
     // is api path
     const baseUrl = req.baseUrl || '';
     const baseUrl = req.baseUrl || '';
     if (baseUrl.match(/^\/_api\/.+$/)) {
     if (baseUrl.match(/^\/_api\/.+$/)) {

+ 88 - 27
packages/app/src/server/models/activity.ts

@@ -2,70 +2,94 @@ import { getOrCreateModel, getModelSafely } from '@growi/core';
 import {
 import {
   Types, Document, Model, Schema,
   Types, Document, Model, Schema,
 } from 'mongoose';
 } from 'mongoose';
+import mongoosePaginate from 'mongoose-paginate-v2';
 
 
-import { AllSupportedTargetModelType, AllSupportedEventModelType, AllSupportedActionType } from '~/interfaces/activity';
+import {
+  IActivity, ISnapshot, AllSupportedActions, SupportedActionType,
+  AllSupportedTargetModels, SupportedTargetModelType,
+  AllSupportedEventModels, SupportedEventModelType,
+} from '~/interfaces/activity';
 
 
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
-import activityEvent from '../events/activity';
 
 
 import Subscription from './subscription';
 import Subscription from './subscription';
 
 
+
 const logger = loggerFactory('growi:models:activity');
 const logger = loggerFactory('growi:models:activity');
 
 
 export interface ActivityDocument extends Document {
 export interface ActivityDocument extends Document {
   _id: Types.ObjectId
   _id: Types.ObjectId
-  user: Types.ObjectId | any
-  targetModel: string
+  user: Types.ObjectId
+  ip: string
+  endpoint: string
+  targetModel: SupportedTargetModelType
   target: Types.ObjectId
   target: Types.ObjectId
-  action: string
+  eventModel: SupportedEventModelType
   event: Types.ObjectId
   event: Types.ObjectId
-  eventModel: string
+  action: SupportedActionType
+  snapshot: ISnapshot
 
 
   getNotificationTargetUsers(): Promise<any[]>
   getNotificationTargetUsers(): Promise<any[]>
 }
 }
 
 
 export interface ActivityModel extends Model<ActivityDocument> {
 export interface ActivityModel extends Model<ActivityDocument> {
+  [x:string]: any
   getActionUsersFromActivities(activities: ActivityDocument[]): any[]
   getActionUsersFromActivities(activities: ActivityDocument[]): any[]
 }
 }
+
+const snapshotSchema = new Schema<ISnapshot>({
+  username: { type: String, index: true },
+});
+
 // TODO: add revision id
 // TODO: add revision id
 const activitySchema = new Schema<ActivityDocument, ActivityModel>({
 const activitySchema = new Schema<ActivityDocument, ActivityModel>({
   user: {
   user: {
     type: Schema.Types.ObjectId,
     type: Schema.Types.ObjectId,
     ref: 'User',
     ref: 'User',
     index: true,
     index: true,
-    require: true,
+  },
+  ip: {
+    type: String,
+  },
+  endpoint: {
+    type: String,
   },
   },
   targetModel: {
   targetModel: {
     type: String,
     type: String,
-    require: true,
-    enum: AllSupportedTargetModelType,
+    enum: AllSupportedTargetModels,
   },
   },
   target: {
   target: {
     type: Schema.Types.ObjectId,
     type: Schema.Types.ObjectId,
     refPath: 'targetModel',
     refPath: 'targetModel',
-    require: true,
   },
   },
-  action: {
+  eventModel: {
     type: String,
     type: String,
-    require: true,
-    enum: AllSupportedActionType,
+    enum: AllSupportedEventModels,
   },
   },
   event: {
   event: {
     type: Schema.Types.ObjectId,
     type: Schema.Types.ObjectId,
-    refPath: 'eventModel',
   },
   },
-  eventModel: {
+  action: {
     type: String,
     type: String,
-    enum: AllSupportedEventModelType,
+    enum: AllSupportedActions,
+    required: true,
   },
   },
+  snapshot: snapshotSchema,
 }, {
 }, {
-  timestamps: true,
+  timestamps: {
+    createdAt: true,
+    updatedAt: false,
+  },
 });
 });
 activitySchema.index({ target: 1, action: 1 });
 activitySchema.index({ target: 1, action: 1 });
 activitySchema.index({
 activitySchema.index({
   user: 1, target: 1, action: 1, createdAt: 1,
   user: 1, target: 1, action: 1, createdAt: 1,
 }, { unique: true });
 }, { unique: true });
+activitySchema.plugin(mongoosePaginate);
 
 
+activitySchema.post('save', function() {
+  logger.debug('activity has been created', this);
+});
 
 
 activitySchema.methods.getNotificationTargetUsers = async function() {
 activitySchema.methods.getNotificationTargetUsers = async function() {
   const User = getModelSafely('User') || require('~/server/models/user')();
   const User = getModelSafely('User') || require('~/server/models/user')();
@@ -89,16 +113,53 @@ activitySchema.methods.getNotificationTargetUsers = async function() {
   return activeNotificationUsers;
   return activeNotificationUsers;
 };
 };
 
 
-activitySchema.post('save', async(savedActivity: ActivityDocument) => {
-  let targetUsers: Types.ObjectId[] = [];
-  try {
-    targetUsers = await savedActivity.getNotificationTargetUsers();
-  }
-  catch (err) {
-    logger.error(err);
-  }
+activitySchema.statics.createByParameters = async function(parameters): Promise<IActivity> {
+  const activity = await this.create(parameters) as unknown as IActivity;
 
 
-  activityEvent.emit('create', targetUsers, savedActivity);
-});
+  return activity;
+};
+
+// When using this method, ensure that activity updates are allowed using ActivityService.shoudUpdateActivity
+activitySchema.statics.updateByParameters = async function(activityId: string, parameters): Promise<IActivity> {
+  const activity = await this.findOneAndUpdate({ _id: activityId }, parameters, { new: true }) as unknown as IActivity;
+
+  return activity;
+};
+
+activitySchema.statics.getPaginatedActivity = async function(limit: number, offset: number, query) {
+  const paginateResult = await this.paginate(
+    query,
+    {
+      limit,
+      offset,
+      sort: { createdAt: -1 },
+    },
+  );
+  return paginateResult;
+};
+
+activitySchema.statics.findSnapshotUsernamesByUsernameRegexWithTotalCount = async function(
+    q: string, option: { sortOpt: number | string, offset: number, limit: number},
+): Promise<{usernames: string[], totalCount: number}> {
+  const opt = option || {};
+  const sortOpt = opt.sortOpt || 1;
+  const offset = opt.offset || 0;
+  const limit = opt.limit || 10;
+
+  const conditions = { 'snapshot.username': { $regex: q, $options: 'i' } };
+
+  const usernames = await this.aggregate()
+    .skip(0)
+    .limit(10000) // Narrow down the search target
+    .match(conditions)
+    .group({ _id: '$snapshot.username' })
+    .sort({ _id: sortOpt }) // Sort "snapshot.username" in ascending order
+    .skip(offset)
+    .limit(limit);
+
+  const totalCount = (await this.find(conditions).distinct('snapshot.username')).length;
+
+  return { usernames: usernames.map(r => r._id), totalCount };
+};
 
 
 export default getOrCreateModel<ActivityDocument, ActivityModel>('Activity', activitySchema);
 export default getOrCreateModel<ActivityDocument, ActivityModel>('Activity', activitySchema);

+ 2 - 2
packages/app/src/server/models/comment.js

@@ -99,7 +99,7 @@ module.exports = function(crowi) {
    * post remove hook
    * post remove hook
    */
    */
   commentSchema.post('reomove', async(savedComment) => {
   commentSchema.post('reomove', async(savedComment) => {
-    await commentEvent.emit('remove', savedComment);
+    await commentEvent.emit('delete', savedComment);
   });
   });
 
 
   commentSchema.methods.removeWithReplies = async function(comment) {
   commentSchema.methods.removeWithReplies = async function(comment) {
@@ -110,7 +110,7 @@ module.exports = function(crowi) {
         [{ replyTo: this._id }, { _id: this._id }]),
         [{ replyTo: this._id }, { _id: this._id }]),
     });
     });
 
 
-    await commentEvent.emit('remove', comment);
+    await commentEvent.emit('delete', comment);
     return;
     return;
   };
   };
 
 

+ 3 - 0
packages/app/src/server/models/config.ts

@@ -245,6 +245,9 @@ schema.statics.getLocalconfig = function(crowi) {
     globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
     globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
     pageLimitationL: crowi.configManager.getConfig('crowi', 'customize:showPageLimitationL'),
     pageLimitationL: crowi.configManager.getConfig('crowi', 'customize:showPageLimitationL'),
     pageLimitationXL: crowi.configManager.getConfig('crowi', 'customize:showPageLimitationXL'),
     pageLimitationXL: crowi.configManager.getConfig('crowi', 'customize:showPageLimitationXL'),
+    auditLogEnabled: crowi.configManager.getConfig('crowi', 'app:auditLogEnabled'),
+    activityExpirationSeconds: crowi.configManager.getConfig('crowi', 'app:activityExpirationSeconds'),
+    auditLogAvailableActions: crowi.activityService.getAvailableActions(false),
     isSidebarDrawerMode: crowi.configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     isSidebarDrawerMode: crowi.configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     isSidebarClosedAtDockMode: crowi.configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
     isSidebarClosedAtDockMode: crowi.configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
   };
   };

+ 2 - 2
packages/app/src/server/models/in-app-notification-settings.ts

@@ -1,5 +1,5 @@
-import { Schema, Model, Document } from 'mongoose';
 import { getOrCreateModel } from '@growi/core';
 import { getOrCreateModel } from '@growi/core';
+import { Schema, Model, Document } from 'mongoose';
 
 
 import { IInAppNotificationSettings, subscribeRuleNames } from '~/interfaces/in-app-notification';
 import { IInAppNotificationSettings, subscribeRuleNames } from '~/interfaces/in-app-notification';
 
 
@@ -10,7 +10,7 @@ const inAppNotificationSettingsSchema = new Schema<InAppNotificationSettingsDocu
   userId: { type: Schema.Types.ObjectId },
   userId: { type: Schema.Types.ObjectId },
   subscribeRules: [
   subscribeRules: [
     {
     {
-      name: { type: String, require: true, enum: subscribeRuleNames },
+      name: { type: String, required: true, enum: subscribeRuleNames },
       isEnabled: { type: Boolean },
       isEnabled: { type: Boolean },
     },
     },
   ],
   ],

+ 9 - 9
packages/app/src/server/models/in-app-notification.ts

@@ -4,7 +4,7 @@ import {
 } from 'mongoose';
 } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import mongoosePaginate from 'mongoose-paginate-v2';
 
 
-import { AllSupportedTargetModelType, AllSupportedActionType } from '~/interfaces/activity';
+import { AllSupportedTargetModels, AllSupportedActions } from '~/interfaces/activity';
 import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 
 
 import { ActivityDocument } from './activity';
 import { ActivityDocument } from './activity';
@@ -41,22 +41,22 @@ const inAppNotificationSchema = new Schema<InAppNotificationDocument, InAppNotif
     type: Schema.Types.ObjectId,
     type: Schema.Types.ObjectId,
     ref: 'User',
     ref: 'User',
     index: true,
     index: true,
-    require: true,
+    required: true,
   },
   },
   targetModel: {
   targetModel: {
     type: String,
     type: String,
-    require: true,
-    enum: AllSupportedTargetModelType,
+    required: true,
+    enum: AllSupportedTargetModels,
   },
   },
   target: {
   target: {
     type: Schema.Types.ObjectId,
     type: Schema.Types.ObjectId,
     refPath: 'targetModel',
     refPath: 'targetModel',
-    require: true,
+    required: true,
   },
   },
   action: {
   action: {
     type: String,
     type: String,
-    require: true,
-    enum: AllSupportedActionType,
+    required: true,
+    enum: AllSupportedActions,
   },
   },
   activities: [
   activities: [
     {
     {
@@ -69,11 +69,11 @@ const inAppNotificationSchema = new Schema<InAppNotificationDocument, InAppNotif
     default: STATUS_UNREAD,
     default: STATUS_UNREAD,
     enum: InAppNotificationStatuses,
     enum: InAppNotificationStatuses,
     index: true,
     index: true,
-    require: true,
+    required: true,
   },
   },
   snapshot: {
   snapshot: {
     type: String,
     type: String,
-    require: true,
+    required: true,
   },
   },
 }, {
 }, {
   timestamps: { createdAt: true, updatedAt: false },
   timestamps: { createdAt: true, updatedAt: false },

+ 1 - 1
packages/app/src/server/interfaces/page-operation.ts → packages/app/src/server/models/interfaces/page-operation.ts

@@ -1,4 +1,4 @@
-import { ObjectIdLike } from './mongoose-utils';
+import { ObjectIdLike } from '../../interfaces/mongoose-utils';
 
 
 export type IPageForResuming = {
 export type IPageForResuming = {
   _id: ObjectIdLike,
   _id: ObjectIdLike,

+ 1 - 12
packages/app/src/server/models/obsolete-page.js

@@ -98,7 +98,7 @@ export const getPageSchema = (crowi) => {
   }
   }
 
 
   pageSchema.methods.isDeleted = function() {
   pageSchema.methods.isDeleted = function() {
-    return (this.status === STATUS_DELETED) || isTrashPage(this.path);
+    return isTrashPage(this.path);
   };
   };
 
 
   pageSchema.methods.isPublic = function() {
   pageSchema.methods.isPublic = function() {
@@ -286,17 +286,6 @@ export const getPageSchema = (crowi) => {
       });
       });
   };
   };
 
 
-  pageSchema.statics.getGrantLabels = function() {
-    const grantLabels = {};
-    grantLabels[GRANT_PUBLIC] = 'Public'; // 公開
-    grantLabels[GRANT_RESTRICTED] = 'Anyone with the link'; // リンクを知っている人のみ
-    // grantLabels[GRANT_SPECIFIED]  = 'Specified users only'; // 特定ユーザーのみ
-    grantLabels[GRANT_USER_GROUP] = 'Only inside the group'; // 特定グループのみ
-    grantLabels[GRANT_OWNER] = 'Only me'; // 自分のみ
-
-    return grantLabels;
-  };
-
   pageSchema.statics.getUserPagePath = function(user) {
   pageSchema.statics.getUserPagePath = function(user) {
     return `/user/${user.username}`;
     return `/user/${user.username}`;
   };
   };

+ 1 - 1
packages/app/src/server/models/page-operation.ts

@@ -6,7 +6,7 @@ import mongoose, {
 
 
 import {
 import {
   IPageForResuming, IUserForResuming, IOptionsForResuming,
   IPageForResuming, IUserForResuming, IOptionsForResuming,
-} from '~/server/interfaces/page-operation';
+} from '~/server/models/interfaces/page-operation';
 
 
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { ObjectIdLike } from '../interfaces/mongoose-utils';

+ 5 - 5
packages/app/src/server/models/subscription.ts

@@ -3,7 +3,7 @@ import {
   Types, Document, Model, Schema,
   Types, Document, Model, Schema,
 } from 'mongoose';
 } from 'mongoose';
 
 
-import { AllSupportedTargetModelType } from '~/interfaces/activity';
+import { AllSupportedTargetModels } from '~/interfaces/activity';
 import { SubscriptionStatusType, AllSubscriptionStatusType } from '~/interfaces/subscription';
 import { SubscriptionStatusType, AllSubscriptionStatusType } from '~/interfaces/subscription';
 
 
 
 
@@ -37,17 +37,17 @@ const subscriptionSchema = new Schema<SubscriptionDocument, SubscriptionModel>({
   },
   },
   targetModel: {
   targetModel: {
     type: String,
     type: String,
-    require: true,
-    enum: AllSupportedTargetModelType,
+    required: true,
+    enum: AllSupportedTargetModels,
   },
   },
   target: {
   target: {
     type: Schema.Types.ObjectId,
     type: Schema.Types.ObjectId,
     refPath: 'targetModel',
     refPath: 'targetModel',
-    require: true,
+    required: true,
   },
   },
   status: {
   status: {
     type: String,
     type: String,
-    require: true,
+    required: true,
     enum: AllSubscriptionStatusType,
     enum: AllSubscriptionStatusType,
   },
   },
 }, {
 }, {

+ 2 - 2
packages/app/src/server/models/user.js

@@ -1,5 +1,5 @@
 /* eslint-disable no-use-before-define */
 /* eslint-disable no-use-before-define */
-import { allLocales } from '~/next-i18next.config';
+import { i18n } from '~/next-i18next.config';
 import { generateGravatarSrc } from '~/utils/gravatar';
 import { generateGravatarSrc } from '~/utils/gravatar';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -59,7 +59,7 @@ module.exports = function(crowi) {
     apiToken: { type: String, index: true },
     apiToken: { type: String, index: true },
     lang: {
     lang: {
       type: String,
       type: String,
-      enum: allLocales,
+      enum: i18n.locales,
       default: 'en_US',
       default: 'en_US',
     },
     },
     status: {
     status: {

+ 18 - 2
packages/app/src/server/routes/admin.js

@@ -1,5 +1,6 @@
-import loggerFactory from '~/utils/logger';
+import { SupportedAction } from '~/interfaces/activity';
 import UserGroup from '~/server/models/user-group';
 import UserGroup from '~/server/models/user-group';
+import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('growi:routes:admin');
 const logger = loggerFactory('growi:routes:admin');
 const debug = require('debug')('growi:routes:admin');
 const debug = require('debug')('growi:routes:admin');
@@ -27,6 +28,8 @@ module.exports = function(crowi, app) {
 
 
   const { check, param } = require('express-validator');
   const { check, param } = require('express-validator');
 
 
+  const activityEvent = crowi.event('activity');
+
   const api = {};
   const api = {};
 
 
   function createPager(total, limit, page, pagesCount, maxPageList) {
   function createPager(total, limit, page, pagesCount, maxPageList) {
@@ -289,6 +292,12 @@ module.exports = function(crowi, app) {
     return res.render('admin/user-group-detail', { userGroup });
     return res.render('admin/user-group-detail', { userGroup });
   };
   };
 
 
+  // AuditLog
+  actions.auditLog = {};
+  actions.auditLog.index = (req, res) => {
+    return res.render('admin/audit-log');
+  };
+
   // Importer management
   // Importer management
   actions.importer = {};
   actions.importer = {};
   actions.importer.api = api;
   actions.importer.api = api;
@@ -375,6 +384,8 @@ module.exports = function(crowi, app) {
 
 
     await configManager.updateConfigsInTheSameNamespace('crowi', form);
     await configManager.updateConfigsInTheSameNamespace('crowi', form);
     importer.initializeEsaClient(); // let it run in the back aftert res
     importer.initializeEsaClient(); // let it run in the back aftert res
+    const parameters = { action: SupportedAction.ACTION_ADMIN_ESA_DATA_UPDATED };
+    activityEvent.emit('update', res.locals.activity._id, parameters);
     return res.json(ApiResponse.success());
     return res.json(ApiResponse.success());
   };
   };
 
 
@@ -395,7 +406,8 @@ module.exports = function(crowi, app) {
 
 
     await configManager.updateConfigsInTheSameNamespace('crowi', form);
     await configManager.updateConfigsInTheSameNamespace('crowi', form);
     importer.initializeQiitaClient(); // let it run in the back aftert res
     importer.initializeQiitaClient(); // let it run in the back aftert res
-
+    const parameters = { action: SupportedAction.ACTION_ADMIN_QIITA_DATA_UPDATED };
+    activityEvent.emit('update', res.locals.activity._id, parameters);
     return res.json(ApiResponse.success());
     return res.json(ApiResponse.success());
   };
   };
 
 
@@ -454,6 +466,8 @@ module.exports = function(crowi, app) {
   actions.api.testEsaAPI = async(req, res) => {
   actions.api.testEsaAPI = async(req, res) => {
     try {
     try {
       await importer.testConnectionToEsa();
       await importer.testConnectionToEsa();
+      const parameters = { action: SupportedAction.ACTION_ADMIN_CONNECTION_TEST_OF_ESA_DATA };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.json(ApiResponse.success());
       return res.json(ApiResponse.success());
     }
     }
     catch (err) {
     catch (err) {
@@ -470,6 +484,8 @@ module.exports = function(crowi, app) {
   actions.api.testQiitaAPI = async(req, res) => {
   actions.api.testQiitaAPI = async(req, res) => {
     try {
     try {
       await importer.testConnectionToQiita();
       await importer.testConnectionToQiita();
+      const parameters = { action: SupportedAction.ACTION_ADMIN_CONNECTION_TEST_OF_QIITA_DATA };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.json(ApiResponse.success());
       return res.json(ApiResponse.success());
     }
     }
     catch (err) {
     catch (err) {

+ 113 - 0
packages/app/src/server/routes/apiv3/activity.ts

@@ -0,0 +1,113 @@
+import { parseISO, addMinutes, isValid } from 'date-fns';
+import express, { Request, Router } from 'express';
+import rateLimit from 'express-rate-limit';
+import { query } from 'express-validator';
+
+import { ISearchFilter } from '~/interfaces/activity';
+import Activity from '~/server/models/activity';
+import loggerFactory from '~/utils/logger';
+
+import Crowi from '../../crowi';
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
+import { ApiV3Response } from './interfaces/apiv3-response';
+
+
+const logger = loggerFactory('growi:routes:apiv3:activity');
+
+
+const validator = {
+  list: [
+    query('limit').optional().isInt({ max: 100 }).withMessage('limit must be a number less than or equal to 100'),
+    query('offset').optional().isInt().withMessage('page must be a number'),
+    query('searchFilter').optional().isString().withMessage('query must be a string'),
+  ],
+};
+
+const apiLimiter = rateLimit({
+  windowMs: 15 * 60 * 1000, // 15 minutes
+  max: 30, // limit each IP to 30 requests per windowMs
+  message:
+    'Too many requests sent from this IP, please try again after 15 minutes.',
+});
+
+module.exports = (crowi: Crowi): Router => {
+  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+
+  const router = express.Router();
+
+  // eslint-disable-next-line max-len
+  router.get('/', apiLimiter, accessTokenParser, loginRequiredStrictly, adminRequired, validator.list, apiV3FormValidator, async(req: Request, res: ApiV3Response) => {
+    const auditLogEnabled = crowi.configManager?.getConfig('crowi', 'app:auditLogEnabled') || false;
+    if (!auditLogEnabled) {
+      const msg = 'AuditLog is not enabled';
+      logger.error(msg);
+      return res.apiv3Err(msg, 405);
+    }
+
+    const limit = req.query.limit || await crowi.configManager?.getConfig('crowi', 'customize:showPageLimitationS') || 10;
+    const offset = req.query.offset || 1;
+
+    const query = {};
+
+    try {
+      const parsedSearchFilter = JSON.parse(req.query.searchFilter as string) as ISearchFilter;
+
+      // add username to query
+      const canContainUsernameFilterToQuery = (
+        parsedSearchFilter.usernames != null
+        && parsedSearchFilter.usernames.length > 0
+        && parsedSearchFilter.usernames.every(u => typeof u === 'string')
+      );
+      if (canContainUsernameFilterToQuery) {
+        Object.assign(query, { 'snapshot.username': parsedSearchFilter.usernames });
+      }
+
+      // add action to query
+      if (parsedSearchFilter.actions != null) {
+        const availableActions = crowi.activityService.getAvailableActions(false);
+        const searchableActions = parsedSearchFilter.actions.filter(action => availableActions.includes(action));
+        Object.assign(query, { action: searchableActions });
+      }
+
+      // add date to query
+      const startDate = parseISO(parsedSearchFilter?.dates?.startDate || '');
+      const endDate = parseISO(parsedSearchFilter?.dates?.endDate || '');
+      if (isValid(startDate) && isValid(endDate)) {
+        Object.assign(query, {
+          createdAt: {
+            $gte: startDate,
+            // + 23 hours 59 minutes
+            $lt: addMinutes(endDate, 1439),
+          },
+        });
+      }
+      else if (isValid(startDate) && !isValid(endDate)) {
+        Object.assign(query, {
+          createdAt: {
+            $gte: startDate,
+            // + 23 hours 59 minutes
+            $lt: addMinutes(startDate, 1439),
+          },
+        });
+      }
+    }
+    catch (err) {
+      logger.error('Invalid value', err);
+      return res.apiv3Err(err, 400);
+    }
+
+    try {
+      const paginationResult = await Activity.getPaginatedActivity(limit, offset, query);
+      return res.apiv3({ paginationResult });
+    }
+    catch (err) {
+      logger.error('Failed to get paginated activity', err);
+      return res.apiv3Err(err, 500);
+    }
+  });
+
+  return router;
+};

+ 41 - 13
packages/app/src/server/routes/apiv3/app-settings.js

@@ -1,10 +1,13 @@
 import { body } from 'express-validator';
 import { body } from 'express-validator';
 
 
-import { allLocales } from '~/next-i18next.config';
+import { SupportedAction } from '~/interfaces/activity';
+import { i18n } from '~/next-i18next.config';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
+
 const logger = loggerFactory('growi:routes:apiv3:app-settings');
 const logger = loggerFactory('growi:routes:apiv3:app-settings');
 
 
 const debug = require('debug')('growi:routes:admin');
 const debug = require('debug')('growi:routes:admin');
@@ -151,12 +154,15 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const activityEvent = crowi.event('activity');
 
 
   const validator = {
   const validator = {
     appSetting: [
     appSetting: [
       body('title').trim(),
       body('title').trim(),
       body('confidential'),
       body('confidential'),
-      body('globalLang').isIn(allLocales),
+      body('globalLang').isIn(i18n.locales),
       body('isEmailPublishedForNewUser').isBoolean(),
       body('isEmailPublishedForNewUser').isBoolean(),
       body('fileUpload').isBoolean(),
       body('fileUpload').isBoolean(),
     ],
     ],
@@ -294,7 +300,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/AppSettingParams'
    *                  $ref: '#/components/schemas/AppSettingParams'
    */
    */
-  router.put('/app-setting', loginRequiredStrictly, adminRequired, csrf, validator.appSetting, apiV3FormValidator, async(req, res) => {
+  router.put('/app-setting', loginRequiredStrictly, adminRequired, csrf, addActivity, validator.appSetting, apiV3FormValidator, async(req, res) => {
     const requestAppSettingParams = {
     const requestAppSettingParams = {
       'app:title': req.body.title,
       'app:title': req.body.title,
       'app:confidential': req.body.confidential,
       'app:confidential': req.body.confidential,
@@ -312,6 +318,10 @@ module.exports = (crowi) => {
         isEmailPublishedForNewUser: crowi.configManager.getConfig('crowi', 'customize:isEmailPublishedForNewUser'),
         isEmailPublishedForNewUser: crowi.configManager.getConfig('crowi', 'customize:isEmailPublishedForNewUser'),
         fileUpload: crowi.configManager.getConfig('crowi', 'app:fileUpload'),
         fileUpload: crowi.configManager.getConfig('crowi', 'app:fileUpload'),
       };
       };
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_APP_SETTINGS_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ appSettingParams });
       return res.apiv3({ appSettingParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -345,7 +355,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/SiteUrlSettingParams'
    *                  $ref: '#/components/schemas/SiteUrlSettingParams'
    */
    */
-  router.put('/site-url-setting', loginRequiredStrictly, adminRequired, csrf, validator.siteUrlSetting, apiV3FormValidator, async(req, res) => {
+  router.put('/site-url-setting', loginRequiredStrictly, adminRequired, csrf, addActivity, validator.siteUrlSetting, apiV3FormValidator, async(req, res) => {
 
 
     const requestSiteUrlSettingParams = {
     const requestSiteUrlSettingParams = {
       'app:siteUrl': pathUtils.removeTrailingSlash(req.body.siteUrl),
       'app:siteUrl': pathUtils.removeTrailingSlash(req.body.siteUrl),
@@ -356,6 +366,9 @@ module.exports = (crowi) => {
       const siteUrlSettingParams = {
       const siteUrlSettingParams = {
         siteUrl: crowi.configManager.getConfig('crowi', 'app:siteUrl'),
         siteUrl: crowi.configManager.getConfig('crowi', 'app:siteUrl'),
       };
       };
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_SITE_URL_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ siteUrlSettingParams });
       return res.apiv3({ siteUrlSettingParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -477,7 +490,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/SmtpSettingParams'
    *                  $ref: '#/components/schemas/SmtpSettingParams'
    */
    */
-  router.put('/smtp-setting', loginRequiredStrictly, adminRequired, csrf, validator.smtpSetting, apiV3FormValidator, async(req, res) => {
+  router.put('/smtp-setting', loginRequiredStrictly, adminRequired, csrf, addActivity, validator.smtpSetting, apiV3FormValidator, async(req, res) => {
     const requestMailSettingParams = {
     const requestMailSettingParams = {
       'mail:from': req.body.fromAddress,
       'mail:from': req.body.fromAddress,
       'mail:transmissionMethod': req.body.transmissionMethod,
       'mail:transmissionMethod': req.body.transmissionMethod,
@@ -489,6 +502,8 @@ module.exports = (crowi) => {
 
 
     try {
     try {
       const mailSettingParams = await updateMailSettinConfig(requestMailSettingParams);
       const mailSettingParams = await updateMailSettinConfig(requestMailSettingParams);
+      const parameters = { action: SupportedAction.ACTION_ADMIN_MAIL_SMTP_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ mailSettingParams });
       return res.apiv3({ mailSettingParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -511,9 +526,11 @@ module.exports = (crowi) => {
    *          200:
    *          200:
    *            description: Succeeded to send test mail for smtp
    *            description: Succeeded to send test mail for smtp
    */
    */
-  router.post('/smtp-test', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.post('/smtp-test', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
     try {
     try {
       await sendTestEmail(req.user.email);
       await sendTestEmail(req.user.email);
+      const parameters = { action: SupportedAction.ACTION_ADMIN_MAIL_TEST_SUBMIT };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({});
       return res.apiv3({});
     }
     }
     catch (err) {
     catch (err) {
@@ -547,7 +564,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/SesSettingParams'
    *                  $ref: '#/components/schemas/SesSettingParams'
    */
    */
-  router.put('/ses-setting', loginRequiredStrictly, adminRequired, csrf, validator.sesSetting, apiV3FormValidator, async(req, res) => {
+  router.put('/ses-setting', loginRequiredStrictly, adminRequired, csrf, addActivity, validator.sesSetting, apiV3FormValidator, async(req, res) => {
     const { mailService } = crowi;
     const { mailService } = crowi;
 
 
     const requestSesSettingParams = {
     const requestSesSettingParams = {
@@ -569,7 +586,8 @@ module.exports = (crowi) => {
 
 
     await mailService.initialize();
     await mailService.initialize();
     mailService.publishUpdatedMessage();
     mailService.publishUpdatedMessage();
-
+    const parameters = { action: SupportedAction.ACTION_ADMIN_MAIL_SES_UPDATE };
+    activityEvent.emit('update', res.locals.activity._id, parameters);
     return res.apiv3({ mailSettingParams });
     return res.apiv3({ mailSettingParams });
   });
   });
 
 
@@ -596,7 +614,8 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/FileUploadSettingParams'
    *                  $ref: '#/components/schemas/FileUploadSettingParams'
    */
    */
-  router.put('/file-upload-setting', loginRequiredStrictly, adminRequired, csrf, validator.fileUploadSetting, apiV3FormValidator, async(req, res) => {
+  //  eslint-disable-next-line max-len
+  router.put('/file-upload-setting', loginRequiredStrictly, adminRequired, csrf, addActivity, validator.fileUploadSetting, apiV3FormValidator, async(req, res) => {
     const { fileUploadType } = req.body;
     const { fileUploadType } = req.body;
 
 
     const requestParams = {
     const requestParams = {
@@ -643,7 +662,8 @@ module.exports = (crowi) => {
         responseParams.s3SecretAccessKey = crowi.configManager.getConfig('crowi', 'aws:s3SecretAccessKey');
         responseParams.s3SecretAccessKey = crowi.configManager.getConfig('crowi', 'aws:s3SecretAccessKey');
         responseParams.s3ReferenceFileWithRelayMode = crowi.configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode');
         responseParams.s3ReferenceFileWithRelayMode = crowi.configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode');
       }
       }
-
+      const parameters = { action: SupportedAction.ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ responseParams });
       return res.apiv3({ responseParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -677,7 +697,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/PluginSettingParams'
    *                  $ref: '#/components/schemas/PluginSettingParams'
    */
    */
-  router.put('/plugin-setting', loginRequiredStrictly, adminRequired, csrf, validator.pluginSetting, apiV3FormValidator, async(req, res) => {
+  router.put('/plugin-setting', loginRequiredStrictly, adminRequired, csrf, addActivity, validator.pluginSetting, apiV3FormValidator, async(req, res) => {
     const requestPluginSettingParams = {
     const requestPluginSettingParams = {
       'plugin:isEnabledPlugins': req.body.isEnabledPlugins,
       'plugin:isEnabledPlugins': req.body.isEnabledPlugins,
     };
     };
@@ -687,6 +707,8 @@ module.exports = (crowi) => {
       const pluginSettingParams = {
       const pluginSettingParams = {
         isEnabledPlugins: crowi.configManager.getConfig('crowi', 'plugin:isEnabledPlugins'),
         isEnabledPlugins: crowi.configManager.getConfig('crowi', 'plugin:isEnabledPlugins'),
       };
       };
+      const parameters = { action: SupportedAction.ACTION_ADMIN_PLUGIN_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ pluginSettingParams });
       return res.apiv3({ pluginSettingParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -719,15 +741,17 @@ module.exports = (crowi) => {
   });
   });
 
 
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  router.post('/maintenance-mode', accessTokenParser, loginRequiredStrictly, adminRequired, csrf, validator.maintenanceMode, apiV3FormValidator, async(req, res) => {
+  router.post('/maintenance-mode', accessTokenParser, loginRequiredStrictly, adminRequired, csrf, addActivity, validator.maintenanceMode, apiV3FormValidator, async(req, res) => {
     const { flag } = req.body;
     const { flag } = req.body;
-
+    const parameters = {};
     try {
     try {
       if (flag) {
       if (flag) {
         await crowi.appService.startMaintenanceMode();
         await crowi.appService.startMaintenanceMode();
+        Object.assign(parameters, { action: SupportedAction.ACTION_ADMIN_MAINTENANCEMODE_ENABLED });
       }
       }
       else {
       else {
         await crowi.appService.endMaintenanceMode();
         await crowi.appService.endMaintenanceMode();
+        Object.assign(parameters, { action: SupportedAction.ACTION_ADMIN_MAINTENANCEMODE_DISABLED });
       }
       }
     }
     }
     catch (err) {
     catch (err) {
@@ -740,6 +764,10 @@ module.exports = (crowi) => {
       }
       }
     }
     }
 
 
+    if ('action' in parameters) {
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+    }
+
     res.apiv3({ flag });
     res.apiv3({ flag });
   });
   });
 
 

+ 17 - 6
packages/app/src/server/routes/apiv3/bookmarks.js

@@ -1,11 +1,15 @@
+import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
+
 const logger = loggerFactory('growi:routes:apiv3:bookmarks'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:bookmarks'); // eslint-disable-line no-unused-vars
 
 
 const express = require('express');
 const express = require('express');
 const { body, query, param } = require('express-validator');
 const { body, query, param } = require('express-validator');
+
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
 
 const router = express.Router();
 const router = express.Router();
@@ -71,6 +75,9 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const csrf = require('../../middlewares/csrf')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const activityEvent = crowi.event('activity');
 
 
   const { Page, Bookmark, User } = crowi.models;
   const { Page, Bookmark, User } = crowi.models;
 
 
@@ -257,7 +264,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/Bookmark'
    *                  $ref: '#/components/schemas/Bookmark'
    */
    */
-  router.put('/', accessTokenParser, loginRequiredStrictly, csrf, validator.bookmarks, apiV3FormValidator, async(req, res) => {
+  router.put('/', accessTokenParser, loginRequiredStrictly, csrf, addActivity, validator.bookmarks, apiV3FormValidator, async(req, res) => {
     const { pageId, bool } = req.body;
     const { pageId, bool } = req.body;
     const userId = req.user?._id;
     const userId = req.user?._id;
 
 
@@ -265,9 +272,10 @@ module.exports = (crowi) => {
       return res.apiv3Err('A logged in user is required.');
       return res.apiv3Err('A logged in user is required.');
     }
     }
 
 
+    let page;
     let bookmark;
     let bookmark;
     try {
     try {
-      const page = await Page.findByIdAndViewer(pageId, req.user);
+      page = await Page.findByIdAndViewer(pageId, req.user);
       if (page == null) {
       if (page == null) {
         return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
         return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
       }
       }
@@ -277,10 +285,6 @@ module.exports = (crowi) => {
       if (bookmark == null) {
       if (bookmark == null) {
         if (bool) {
         if (bool) {
           bookmark = await Bookmark.add(page, req.user);
           bookmark = await Bookmark.add(page, req.user);
-
-          const pageEvent = crowi.event('page');
-          // in-app notification
-          pageEvent.emit('bookmark', page, req.user);
         }
         }
         else {
         else {
           logger.warn(`Removing the bookmark for ${page._id} by ${req.user._id} failed because the bookmark does not exist.`);
           logger.warn(`Removing the bookmark for ${page._id} by ${req.user._id} failed because the bookmark does not exist.`);
@@ -306,6 +310,13 @@ module.exports = (crowi) => {
       bookmark.depopulate('user');
       bookmark.depopulate('user');
     }
     }
 
 
+    const parameters = {
+      targetModel: SupportedTargetModel.MODEL_PAGE,
+      target: page,
+      action: bool ? SupportedAction.ACTION_PAGE_BOOKMARK : SupportedAction.ACTION_PAGE_UNBOOKMARK,
+    };
+    activityEvent.emit('update', res.locals.activity._id, parameters, page);
+
     return res.apiv3({ bookmark });
     return res.apiv3({ bookmark });
   });
   });
 
 

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

@@ -1,8 +1,11 @@
 /* eslint-disable no-unused-vars */
 /* eslint-disable no-unused-vars */
+import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
+
 const logger = loggerFactory('growi:routes:apiv3:customize-setting');
 const logger = loggerFactory('growi:routes:apiv3:customize-setting');
 
 
 const express = require('express');
 const express = require('express');
@@ -93,6 +96,9 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const activityEvent = crowi.event('activity');
 
 
   const { customizeService } = crowi;
   const { customizeService } = crowi;
 
 
@@ -240,7 +246,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/CustomizeLayout'
    *                  $ref: '#/components/schemas/CustomizeLayout'
    */
    */
-  router.put('/layout', loginRequiredStrictly, adminRequired, csrf, validator.layout, apiV3FormValidator, async(req, res) => {
+  router.put('/layout', loginRequiredStrictly, adminRequired, csrf, addActivity, validator.layout, apiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
       'customize:isContainerFluid': req.body.isContainerFluid,
       'customize:isContainerFluid': req.body.isContainerFluid,
     };
     };
@@ -250,6 +256,10 @@ module.exports = (crowi) => {
       const customizedParams = {
       const customizedParams = {
         isContainerFluid: await crowi.configManager.getConfig('crowi', 'customize:isContainerFluid'),
         isContainerFluid: await crowi.configManager.getConfig('crowi', 'customize:isContainerFluid'),
       };
       };
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_LAYOUT_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ customizedParams });
       return res.apiv3({ customizedParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -293,7 +303,6 @@ module.exports = (crowi) => {
     if (assetPath == null) {
     if (assetPath == null) {
       return res.apiv3Err(new ErrorV3(`The asset for '${webpackAssetKey}' is undefined.`, 'invalid-asset'));
       return res.apiv3Err(new ErrorV3(`The asset for '${webpackAssetKey}' is undefined.`, 'invalid-asset'));
     }
     }
-
     return res.apiv3({ assetPath });
     return res.apiv3({ assetPath });
   });
   });
 
 
@@ -320,7 +329,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/CustomizeTheme'
    *                  $ref: '#/components/schemas/CustomizeTheme'
    */
    */
-  router.put('/theme', loginRequiredStrictly, adminRequired, csrf, validator.theme, apiV3FormValidator, async(req, res) => {
+  router.put('/theme', loginRequiredStrictly, adminRequired, csrf, addActivity, validator.theme, apiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
       'customize:theme': req.body.themeType,
       'customize:theme': req.body.themeType,
     };
     };
@@ -330,6 +339,8 @@ module.exports = (crowi) => {
       const customizedParams = {
       const customizedParams = {
         themeType: await crowi.configManager.getConfig('crowi', 'customize:theme'),
         themeType: await crowi.configManager.getConfig('crowi', 'customize:theme'),
       };
       };
+      const parameters = { action: SupportedAction.ACTION_ADMIN_THEME_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ customizedParams });
       return res.apiv3({ customizedParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -398,7 +409,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/CustomizeFunction'
    *                  $ref: '#/components/schemas/CustomizeFunction'
    */
    */
-  router.put('/function', loginRequiredStrictly, adminRequired, csrf, validator.function, apiV3FormValidator, async(req, res) => {
+  router.put('/function', loginRequiredStrictly, adminRequired, csrf, addActivity, validator.function, apiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
       'customize:isEnabledTimeline': req.body.isEnabledTimeline,
       'customize:isEnabledTimeline': req.body.isEnabledTimeline,
       'customize:isSavedStatesOfTabChanges': req.body.isSavedStatesOfTabChanges,
       'customize:isSavedStatesOfTabChanges': req.body.isSavedStatesOfTabChanges,
@@ -426,6 +437,8 @@ module.exports = (crowi) => {
         isAllReplyShown: await crowi.configManager.getConfig('crowi', 'customize:isAllReplyShown'),
         isAllReplyShown: await crowi.configManager.getConfig('crowi', 'customize:isAllReplyShown'),
         isSearchScopeChildrenAsDefault: await crowi.configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault'),
         isSearchScopeChildrenAsDefault: await crowi.configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault'),
       };
       };
+      const parameters = { action: SupportedAction.ACTION_ADMIN_FUNCTION_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ customizedParams });
       return res.apiv3({ customizedParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -458,7 +471,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/CustomizeHighlight'
    *                  $ref: '#/components/schemas/CustomizeHighlight'
    */
    */
-  router.put('/highlight', loginRequiredStrictly, adminRequired, csrf, validator.highlight, apiV3FormValidator, async(req, res) => {
+  router.put('/highlight', loginRequiredStrictly, adminRequired, csrf, addActivity, validator.highlight, apiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
       'customize:highlightJsStyle': req.body.highlightJsStyle,
       'customize:highlightJsStyle': req.body.highlightJsStyle,
       'customize:highlightJsStyleBorder': req.body.highlightJsStyleBorder,
       'customize:highlightJsStyleBorder': req.body.highlightJsStyleBorder,
@@ -470,6 +483,8 @@ module.exports = (crowi) => {
         styleName: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyle'),
         styleName: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyle'),
         styleBorder: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
         styleBorder: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
       };
       };
+      const parameters = { action: SupportedAction.ACTION_ADMIN_CODE_HIGHLIGHT_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ customizedParams });
       return res.apiv3({ customizedParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -502,7 +517,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/CustomizeTitle'
    *                  $ref: '#/components/schemas/CustomizeTitle'
    */
    */
-  router.put('/customize-title', loginRequiredStrictly, adminRequired, csrf, validator.customizeTitle, apiV3FormValidator, async(req, res) => {
+  router.put('/customize-title', loginRequiredStrictly, adminRequired, csrf, addActivity, validator.customizeTitle, apiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
       'customize:title': req.body.customizeTitle,
       'customize:title': req.body.customizeTitle,
     };
     };
@@ -515,6 +530,8 @@ module.exports = (crowi) => {
         customizeTitle: await crowi.configManager.getConfig('crowi', 'customize:title'),
         customizeTitle: await crowi.configManager.getConfig('crowi', 'customize:title'),
       };
       };
       customizeService.initCustomTitle();
       customizeService.initCustomTitle();
+      const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_TITLE_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ customizedParams });
       return res.apiv3({ customizedParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -547,7 +564,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/CustomizeHeader'
    *                  $ref: '#/components/schemas/CustomizeHeader'
    */
    */
-  router.put('/customize-header', loginRequiredStrictly, adminRequired, csrf, validator.customizeHeader, apiV3FormValidator, async(req, res) => {
+  router.put('/customize-header', loginRequiredStrictly, adminRequired, csrf, addActivity, validator.customizeHeader, apiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
       'customize:header': req.body.customizeHeader,
       'customize:header': req.body.customizeHeader,
     };
     };
@@ -556,6 +573,8 @@ module.exports = (crowi) => {
       const customizedParams = {
       const customizedParams = {
         customizeHeader: await crowi.configManager.getConfig('crowi', 'customize:header'),
         customizeHeader: await crowi.configManager.getConfig('crowi', 'customize:header'),
       };
       };
+      const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_HTML_HEADER_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ customizedParams });
       return res.apiv3({ customizedParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -588,7 +607,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/CustomizeCss'
    *                  $ref: '#/components/schemas/CustomizeCss'
    */
    */
-  router.put('/customize-css', loginRequiredStrictly, adminRequired, csrf, validator.customizeCss, apiV3FormValidator, async(req, res) => {
+  router.put('/customize-css', loginRequiredStrictly, adminRequired, csrf, addActivity, validator.customizeCss, apiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
       'customize:css': req.body.customizeCss,
       'customize:css': req.body.customizeCss,
     };
     };
@@ -600,6 +619,8 @@ module.exports = (crowi) => {
         customizeCss: await crowi.configManager.getConfig('crowi', 'customize:css'),
         customizeCss: await crowi.configManager.getConfig('crowi', 'customize:css'),
       };
       };
       customizeService.initCustomCss();
       customizeService.initCustomCss();
+      const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_CSS_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ customizedParams });
       return res.apiv3({ customizedParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -632,7 +653,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/CustomizeScript'
    *                  $ref: '#/components/schemas/CustomizeScript'
    */
    */
-  router.put('/customize-script', loginRequiredStrictly, adminRequired, csrf, validator.customizeScript, apiV3FormValidator, async(req, res) => {
+  router.put('/customize-script', loginRequiredStrictly, adminRequired, csrf, addActivity, validator.customizeScript, apiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
       'customize:script': req.body.customizeScript,
       'customize:script': req.body.customizeScript,
     };
     };
@@ -641,6 +662,8 @@ module.exports = (crowi) => {
       const customizedParams = {
       const customizedParams = {
         customizeScript: await crowi.configManager.getConfig('crowi', 'customize:script'),
         customizeScript: await crowi.configManager.getConfig('crowi', 'customize:script'),
       };
       };
+      const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_SCRIPT_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ customizedParams });
       return res.apiv3({ customizedParams });
     }
     }
     catch (err) {
     catch (err) {

+ 9 - 1
packages/app/src/server/routes/apiv3/export.js

@@ -1,7 +1,10 @@
+import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
+
 const logger = loggerFactory('growi:routes:apiv3:export');
 const logger = loggerFactory('growi:routes:apiv3:export');
 const fs = require('fs');
 const fs = require('fs');
 
 
@@ -45,9 +48,11 @@ module.exports = (crowi) => {
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
 
 
   const { exportService, socketIoService } = crowi;
   const { exportService, socketIoService } = crowi;
 
 
+  const activityEvent = crowi.event('activity');
   this.adminEvent = crowi.event('admin');
   this.adminEvent = crowi.event('admin');
 
 
   // setup event
   // setup event
@@ -118,13 +123,16 @@ module.exports = (crowi) => {
    *                  status:
    *                  status:
    *                    $ref: '#/components/schemas/ExportStatus'
    *                    $ref: '#/components/schemas/ExportStatus'
    */
    */
-  router.post('/', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
+  router.post('/', accessTokenParser, loginRequired, adminRequired, csrf, addActivity, async(req, res) => {
     // TODO: add express validator
     // TODO: add express validator
     try {
     try {
       const { collections } = req.body;
       const { collections } = req.body;
 
 
       exportService.export(collections);
       exportService.export(collections);
 
 
+      const parameters = { action: SupportedAction.ACTION_ADMIN_ARCHIVE_DATA_CREATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       // TODO: use res.apiv3
       // TODO: use res.apiv3
       return res.status(200).json({
       return res.status(200).json({
         ok: true,
         ok: true,

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

@@ -42,10 +42,10 @@ module.exports = (crowi) => {
   };
   };
 
 
   const apiLimiter = rateLimit({
   const apiLimiter = rateLimit({
-    windowMs: 15 * 60 * 1000, // 15 minutes
-    max: 10, // limit each IP to 10 requests per windowMs
+    windowMs: 1 * 60 * 1000, // 1 minutes
+    max: 30, // limit each IP to 30 requests per windowMs
     message:
     message:
-      'Too many requests were sent from this IP. Please try a password reset request again on the password reset request form',
+    'Too many requests were sent from this IP. Please try a password reset request again on the password reset request form',
   });
   });
 
 
   const checkPassportStrategyMiddleware = checkForgotPasswordEnabledMiddlewareFactory(crowi, true);
   const checkPassportStrategyMiddleware = checkForgotPasswordEnabledMiddlewareFactory(crowi, true);

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