Ver código fonte

Merge branch 'master' into support/gw7735-VRT-side-bar

cao 3 anos atrás
pai
commit
7f80354f75
100 arquivos alterados com 2471 adições e 1117 exclusões
  1. 1 1
      .devcontainer/devcontainer.json
  2. 3 0
      .github/workflows/reusable-app-prod.yml
  3. 12 14
      .vscode/settings.json
  4. 71 1
      CHANGELOG.md
  5. 1 1
      lerna.json
  6. 4 4
      package.json
  7. 1 0
      packages/app/config/ci/.env.local.for-ci
  8. 1 0
      packages/app/config/webpack.common.js
  9. 2 2
      packages/app/docker/README.md
  10. 14 11
      packages/app/package.json
  11. 182 4
      packages/app/public/static/locales/en_US/admin/admin.json
  12. 9 0
      packages/app/public/static/locales/en_US/translation.json
  13. 181 3
      packages/app/public/static/locales/ja_JP/admin/admin.json
  14. 9 0
      packages/app/public/static/locales/ja_JP/translation.json
  15. 182 3
      packages/app/public/static/locales/zh_CN/admin/admin.json
  16. 9 0
      packages/app/public/static/locales/zh_CN/translation.json
  17. 1 0
      packages/app/regconfig.json
  18. 0 5
      packages/app/src/client/base.jsx
  19. 3 0
      packages/app/src/client/interfaces/clearable.ts
  20. 23 0
      packages/app/src/client/services/page-operation.ts
  21. 34 5
      packages/app/src/components/Admin/AuditLog/ActivityTable.tsx
  22. 19 3
      packages/app/src/components/Admin/AuditLog/AuditLogSettings.tsx
  23. 28 21
      packages/app/src/components/Admin/AuditLog/DateRangePicker.tsx
  24. 18 3
      packages/app/src/components/Admin/AuditLog/SearchUsernameTypeahead.tsx
  25. 2 2
      packages/app/src/components/Admin/AuditLog/SelectActionDropdown.tsx
  26. 87 24
      packages/app/src/components/Admin/AuditLogManagement.tsx
  27. 4 0
      packages/app/src/components/Admin/Customize/Customize.jsx
  28. 185 0
      packages/app/src/components/Admin/Customize/CustomizeLogoSetting.tsx
  29. 4 2
      packages/app/src/components/Admin/ExportArchiveData/SelectCollectionsModal.jsx
  30. 1 1
      packages/app/src/components/Admin/ExportArchiveDataPage.jsx
  31. 0 1
      packages/app/src/components/Admin/ManageExternalAccount.jsx
  32. 21 23
      packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  33. 2 4
      packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  34. 3 5
      packages/app/src/components/Admin/UserGroup/UserGroupModal.tsx
  35. 7 8
      packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  36. 9 12
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  37. 2 1
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx
  38. 2 1
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.jsx
  39. 20 10
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  40. 127 0
      packages/app/src/components/Common/ImageCropModal.tsx
  41. 30 7
      packages/app/src/components/Drawio.tsx
  42. 1 11
      packages/app/src/components/LikeButtons.tsx
  43. 0 125
      packages/app/src/components/Me/ImageCropModal.jsx
  44. 3 2
      packages/app/src/components/Me/ProfileImageSettings.tsx
  45. 8 8
      packages/app/src/components/Navbar/GlobalSearch.tsx
  46. 1 0
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  47. 15 3
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  48. 71 5
      packages/app/src/components/Navbar/SubNavButtons.tsx
  49. 5 1
      packages/app/src/components/Page/FixPageGrantAlert.tsx
  50. 14 1
      packages/app/src/components/Page/RevisionRenderer.tsx
  51. 0 108
      packages/app/src/components/PageAccessoriesModalControl.jsx
  52. 0 404
      packages/app/src/components/PageEditor/Editor.jsx
  53. 362 0
      packages/app/src/components/PageEditor/Editor.tsx
  54. 4 4
      packages/app/src/components/PageList/PageList.tsx
  55. 4 4
      packages/app/src/components/PageList/PageListItemL.tsx
  56. 3 3
      packages/app/src/components/SearchForm.tsx
  57. 4 3
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  58. 7 6
      packages/app/src/components/SearchPage/SearchResultList.tsx
  59. 3 4
      packages/app/src/components/SearchPage2/SearchPageBase.tsx
  60. 6 7
      packages/app/src/components/SearchTypeahead.tsx
  61. 1 1
      packages/app/src/components/ShareLink/ShareLink.jsx
  62. 9 14
      packages/app/src/components/Sidebar/CustomSidebar.tsx
  63. 2 2
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  64. 61 26
      packages/app/src/interfaces/activity.ts
  65. 17 0
      packages/app/src/interfaces/editor-methods.ts
  66. 0 1
      packages/app/src/interfaces/global.ts
  67. 8 0
      packages/app/src/interfaces/graph-viewer.ts
  68. 15 4
      packages/app/src/interfaces/page-operation.ts
  69. 8 6
      packages/app/src/interfaces/page.ts
  70. 10 6
      packages/app/src/interfaces/search.ts
  71. 2 1
      packages/app/src/linter-checker/test.scss
  72. 7 6
      packages/app/src/migrations/20211129125654-initialize-private-legacy-pages-named-query.js
  73. 44 0
      packages/app/src/migrations/20220613064207-add-attachment-type-to-existing-attachments.js
  74. 27 0
      packages/app/src/server/crowi/express-init.js
  75. 2 1
      packages/app/src/server/crowi/index.js
  76. 7 0
      packages/app/src/server/interfaces/attachment.ts
  77. 0 12
      packages/app/src/server/models/activity.ts
  78. 9 2
      packages/app/src/server/models/attachment.js
  79. 5 0
      packages/app/src/server/models/config.ts
  80. 4 1
      packages/app/src/server/models/obsolete-page.js
  81. 1 16
      packages/app/src/server/models/page-operation.ts
  82. 1 0
      packages/app/src/server/models/page.ts
  83. 6 5
      packages/app/src/server/models/password-reset-order.ts
  84. 1 1
      packages/app/src/server/models/user-registration-order.ts
  85. 26 3
      packages/app/src/server/routes/apiv3/activity.ts
  86. 110 4
      packages/app/src/server/routes/apiv3/customize-setting.js
  87. 13 2
      packages/app/src/server/routes/apiv3/forgot-password.js
  88. 2 2
      packages/app/src/server/routes/apiv3/page-listing.ts
  89. 27 2
      packages/app/src/server/routes/apiv3/page.js
  90. 7 1
      packages/app/src/server/routes/apiv3/pages.js
  91. 4 1
      packages/app/src/server/routes/apiv3/search.js
  92. 26 7
      packages/app/src/server/routes/apiv3/users.js
  93. 6 5
      packages/app/src/server/routes/attachment.js
  94. 20 65
      packages/app/src/server/routes/login-passport.js
  95. 4 3
      packages/app/src/server/service/attachment.js
  96. 18 0
      packages/app/src/server/service/config-loader.ts
  97. 15 5
      packages/app/src/server/service/page-operation.ts
  98. 99 12
      packages/app/src/server/service/page.ts
  99. 19 19
      packages/app/src/server/service/search.ts
  100. 3 0
      packages/app/src/server/util/rate-limiter.ts

+ 1 - 1
.devcontainer/devcontainer.json

@@ -25,7 +25,7 @@
     "editorconfig.editorconfig",
     "esbenp.prettier-vscode",
     "shinnn.stylelint",
-    "hex-ci.stylelint-plus",
+    "stylelint.vscode-stylelint"
 	],
 
 	// Uncomment the next line if you want start specific services in your Docker Compose config.

+ 3 - 0
.github/workflows/reusable-app-prod.yml

@@ -206,6 +206,9 @@ jobs:
     steps:
     - uses: actions/checkout@v3
 
+    - name: Install fonts
+      run: sudo apt install fonts-noto
+
     - uses: actions/setup-node@v3
       with:
         node-version: ${{ matrix.node-version }}

+ 12 - 14
.vscode/settings.json

@@ -3,23 +3,21 @@
 
   "eslint.workingDirectories": [{ "mode": "auto" }],
 
-  // use stylelint-plus
-  // see https://qiita.com/y-w/items/bd7f11013fe34b69f0df#vs-code%E3%81%A8%E7%B5%84%E3%81%BF%E5%90%88%E3%82%8F%E3%81%9B%E3%82%8B
+  // use vscode-stylelint
+  // see https://github.com/stylelint/vscode-stylelint
+  "stylelint.validate": ["css", "less", "scss"],
+  "stylelint.ignoreDisables": true,
   "css.validate": false,
+  "less.validate": false,
   "scss.validate": false,
-  "[css]": {
-    "editor.formatOnSave": true
-  },
-  "[scss]": {
-    "editor.formatOnSave": true
-  },
 
-  // for vscode-eslint
-  "[javascript]": {
-    "editor.formatOnSave": false
-  },
   "editor.codeActionsOnSave": {
     "source.fixAll.eslint": true,
-    "source.fixAll.markdownlint": true
-  }
+    "source.fixAll.markdownlint": true,
+    "source.fixAll.stylelint": true
+  },
+
+  "githubPullRequests.ignoredPullRequestBranches": [
+    "master"
+  ]
 }

+ 71 - 1
CHANGELOG.md

@@ -1,9 +1,79 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.0.11...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.1.2...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v5.1.2](https://github.com/weseek/growi/compare/v5.1.1...v5.1.2) - 2022-08-03
+
+### 💎 Features
+
+- feat: Make content width of each page configurable (#6107) @mudana-grune
+
+### 🚀 Improvement
+
+- imprv(auditlog): Clear and reload button (#6398) @miya
+- imprv(auditlog): Date Range Picker  (#6395) @miya
+
+### 🐛 Bug Fixes
+
+- fix: MathJax rendering (#6396) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: Make Editor component Functional Component and TypeScript (#6374) @yukendev
+
+## [v5.1.1](https://github.com/weseek/growi/compare/v5.1.0...v5.1.1) - 2022-08-01
+
+### 💎 Features
+
+- feat: Users can set users per ip from env var at API Rate Limit  (#6379) @yukendev
+- feat: Show user picture in Audit Log (#6342) @miya
+- feat: Reset search criteria button (#6327) @miya
+
+### 🚀 Improvement
+
+- imprv(auditlog): Display number of actions that can be saved (#6353) @miya
+- imprv(auditlog): Include delete-related actions in small group (#6351) @miya
+
+### 🐛 Bug Fixes
+
+- fix: Default markdown linker with relative path does not respect the current page path (v5.1.0) (#6378) @yuki-takei
+- fix: Recover page path operation (#6368) @hakumizuki
+- fix: Migration script for inserting NamedQuery (#6364) @yuki-takei
+- fix: "Error: cannnot get grant label" occured with lsx (#6348) @yukendev
+
+## [v5.1.0](https://github.com/weseek/growi/compare/v5.0.11...v5.1.0) - 2022-07-21
+
+### 💎 Features
+
+- feat: Custom brand logo image (#5709) @mudana-grune
+- feat: Rate Limit by rate-limit-flexible (#6053) @yukendev
+- feat: Audit Log (#5915) @miya
+
+### 🚀 Improvement
+
+- imprv: Prevent XSS with React (#6274) @yuki-takei
+- imprv: Reflect tmp tag data (#6124) @kaoritokashiki
+- imprv: Update subscribe button icon on Navbar (#6213) @jam411
+- imprv: Event emittion by socket.io is triggered only when ES reindexing (#6077) @hirokei-camel
+
+### 🐛 Bug Fixes
+
+- fix: Drawio rendering (#6275) @hakumizuki
+- fix: Blink section header on init (#6249) @yuki-takei
+- fix: Error when trying login with an email that contains plus sign (#6232) @miya
+- fix: Use APIv3 for api get check_username (#6226) @kaoritokashiki
+- fix: Slack integration connection test (#6201) @yukendev
+- fix: Not found page for `/${ObjectId like string}` path (#6208) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: Refactor PageInfo types (#6283) @yuki-takei
+- support: Refactor growi renderer using hooks 2 (#6237) @yuki-takei
+- support: Refactor growi renderer using hooks (#6223) @hakumizuki
+- imprv: Omit Personal Container (#6182) @kaoritokashiki
+
 ## [v5.0.11](https://github.com/weseek/growi/compare/v5.0.10...v5.0.11) - 2022-07-05
 
 ### 💎 Features

+ 1 - 1
lerna.json

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

+ 4 - 4
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "5.1.0-RC.2",
+  "version": "5.1.3-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -75,15 +75,15 @@
     "reg-notify-github-plugin": "^0.11.1",
     "reg-notify-slack-plugin": "^0.11.0",
     "reg-publish-s3-plugin": "^0.11.0",
-    "reg-suit": "^0.11.1",
+    "reg-suit": "^0.12.1",
     "rewire": "^5.0.0",
     "shipjs": "^0.24.1",
     "stylelint": "^14.2.0",
     "stylelint-config-recess-order": "^3.0.0",
     "ts-jest": "^27.0.4",
-    "ts-node": "^9.1.1",
+    "ts-node": "^10.9.1",
     "tsconfig-paths": "^3.9.0",
-    "typescript": "~4.6",
+    "typescript": "~4.7",
     "yargs": "^17.3.1"
   },
   "engines": {

+ 1 - 0
packages/app/config/ci/.env.local.for-ci

@@ -1 +1,2 @@
 FORMAT_NODE_LOG=true
+MATHJAX=1

+ 1 - 0
packages/app/config/webpack.common.js

@@ -76,6 +76,7 @@ module.exports = (options) => {
     },
     node: {
       fs: 'empty',
+      module: 'empty',
     },
     module: {
       rules: options.module.rules.concat([

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

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`5.1.0`, `5.1`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.0/packages/app/docker/Dockerfile)
-* [`5.1.0-nocdn`, `5.1-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.0/packages/app/docker/Dockerfile)
+* [`5.1.2`, `5.1`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.2/docker/Dockerfile)
+* [`5.1.2-nocdn`, `5.1-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.2/docker/Dockerfile)
 * [`5.0.11`, `5.0` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/packages/app/docker/Dockerfile)
 * [`5.0.11-nocdn`, `5.0-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/packages/app/docker/Dockerfile)
 * [`4.5.23`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)

+ 14 - 11
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.1.0-RC.2",
+  "version": "5.1.3-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -59,16 +59,17 @@
   "dependencies": {
     "@aws-sdk/client-s3": "^3.58.0",
     "@aws-sdk/s3-request-presigner": "^3.58.0",
-    "@browser-bunyan/console-formatted-stream": "^1.6.2",
+    "@browser-bunyan/console-formatted-stream": "^1.8.0",
     "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.8",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.1.0-RC.2",
-    "@growi/plugin-attachment-refs": "^5.1.0-RC.2",
-    "@growi/plugin-lsx": "^5.1.0-RC.2",
-    "@growi/plugin-pukiwiki-like-linker": "^5.1.0-RC.2",
-    "@growi/slack": "^5.1.0-RC.2",
+    "@growi/codemirror-textlint": "^5.1.3-RC.0",
+    "@growi/core": "^5.1.3-RC.0",
+    "@growi/plugin-attachment-refs": "^5.1.3-RC.0",
+    "@growi/plugin-lsx": "^5.1.3-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.1.3-RC.0",
+    "@growi/slack": "^5.1.3-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -82,7 +83,7 @@
     "axios": "^0.24.0",
     "axios-retry": "^3.2.4",
     "body-parser": "^1.18.2",
-    "browser-bunyan": "^1.6.3",
+    "browser-bunyan": "^1.8.0",
     "bunyan": "^1.8.15",
     "check-node-version": "^4.1.0",
     "compression": "^1.7.4",
@@ -166,11 +167,12 @@
   "// comments for defDependencies": {
     "@handsontable/react": "v3 requires handsontable >= 7.0.0.",
     "handsontable": "v7.0.0 or above is no loger MIT lisence.",
-    "ts-loader": "v9 is not compatible with webpack@5"
+    "ts-loader": "v9 is not compatible with webpack@5",
+    "ts-node": "v10 occurs 'SyntaxError: Cannot use import statement outside a module' when using migrate-mongo"
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.1.0-RC.2",
+    "@growi/ui": "^5.1.3-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
@@ -254,7 +256,8 @@
     "throttle-debounce": "^3.0.1",
     "toastr": "^2.1.2",
     "ts-loader": "^8.3.0",
-    "ts-node-dev": "^1.1.6",
+    "ts-node": "^9.1.1",
+    "ts-node-dev": "^2.0.0",
     "tsc-alias": "^1.2.9",
     "tsconfig-paths-webpack-plugin": "^3.5.1",
     "unstated": "^2.1.1",

+ 182 - 4
packages/app/public/static/locales/en_US/admin/admin.json

@@ -207,7 +207,13 @@
     "ctrl_space": "Ctrl+Space to autocomplete",
     "custom_script": "Custom script",
     "write_java": "You can write Javascript that is applied to whole system.",
-    "reflect_change": "You need to reload the page to reflect the change."
+    "reflect_change": "You need to reload the page to reflect the change.",
+    "custom_logo" : "Custom Logo",
+    "default_logo": "Default Logo",
+    "upload_logo": "Upload Logo",
+    "current_logo": "Current Logo",
+    "upload_new_logo": "Upload New Logo",
+    "delete_logo": "Delete Logo"
   },
   "importer_management": {
     "beta_warning": "This function is Beta.",
@@ -416,7 +422,7 @@
     }
   },
   "slack_integration_legacy": {
-    "alert_disabled": "This 'Legacy Slack Integration' is currently disabled because <a href='/admin/slack-integration'>the new settings</a> is enabled.",
+    "alert_Pd": "This 'Legacy Slack Integration' is currently disabled because <a href='/admin/slack-integration'>the new settings</a> is enabled.",
     "alert_deplicated": "This 'Legacy Slack Integration' is outdated and will be discontinued in the future. Use <a href='/admin/slack-integration'>the new settings</a> instead. "
   },
   "user_management": {
@@ -523,6 +529,7 @@
     }
   },
   "audit_log_management": {
+    "user": "User",
     "username": "Username",
     "date": "Date",
     "action": "Action",
@@ -530,12 +537,183 @@
     "url": "URL",
     "settings": "Settings",
     "return": "Return",
+    "clear": "Clear",
     "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",
+    "available_action_list_explain": "List of actions that can be searched/viewed in the current settings",
     "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."
+    "disable_mode_explain": "Audit log is currently disabled. To enable it, set the environment variable <code>AUDIT_LOG_ENABLED</code> to true.",
+    "docs_url": {
+      "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
+    }
+  },
+  "audit_log_action_category": {
+    "Page": "Page",
+    "Comment": "Comment",
+    "Tag": "Tag",
+    "Attachment": "Attachment",
+    "ShareLink": "ShareLink",
+    "Search": "Search",
+    "User": "User",
+    "Admin": "Admin"
+  },
+  "audit_log_action": {
+    "USER_REGISTRATION_SUCCESS": "User Creation",
+    "USER_LOGIN_WITH_LOCAL": "Login with ID/Password",
+    "USER_LOGIN_WITH_LDAP": "Login with LDAP",
+    "USER_LOGIN_WITH_GOOGLE": "Login with Google",
+    "USER_LOGIN_WITH_GITHUB": "Login with GitHub",
+    "USER_LOGIN_WITH_TWITTER": "Login with Twitter",
+    "USER_LOGIN_WITH_OIDC": "Login with OIDC",
+    "USER_LOGIN_WITH_SAML": "Login with SAML",
+    "USER_LOGIN_WITH_BASIC": "Login with BASIC",
+    "USER_LOGIN_FAILURE": "Login failure",
+    "USER_LOGOUT": "Logout",
+    "USER_FOGOT_PASSWORD": "Request password reset",
+    "USER_RESET_PASSWORD": "Reset password",
+    "USER_PERSONAL_SETTINGS_UPDATE": "User personal settings update",
+    "USER_IMAGE_TYPE_UPDATE": "User image type update",
+    "USER_LDAP_ACCOUNT_ASSOCIATE": "LDAP account associate",
+    "USER_LDAP_ACCOUNT_DISCONNECT": "LDAP account disconnect",
+    "USER_PASSWORD_UPDATE": "Password update",
+    "USER_API_TOKEN_UPDATE": "API Token update",
+    "USER_EDITOR_SETTINGS_UPDATE": "Editor settings update",
+    "USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE": "In-App Notification settings update",
+    "PAGE_VIEW": "Page view",
+    "PAGE_USER_HOME_VIEW": "Page view (User home)",
+    "PAGE_FORBIDDEN": "Page view (Fobidden page)",
+    "PAGE_NOT_FOUND": "Page view (Not found page)",
+    "PAGE_NOT_CREATABLE": "Page view(Not Creatable page)",
+    "PAGE_LIKE": "Like",
+    "PAGE_UNLIKE": "Unlike",
+    "PAGE_BOOKMARK": "Bookmark",
+    "PAGE_UNBOOKMARK": "Unbookmark",
+    "PAGE_CREATE": "Create page",
+    "PAGE_UPDATE": "Update page",
+    "PAGE_RENAME": "Rename page",
+    "PAGE_DUPLICATE": "Duplicate page",
+    "PAGE_DELETE": "Delete page",
+    "PAGE_DELETE_COMPLETELY": "Delete completely page",
+    "PAGE_REVERT": "Revert page",
+    "PAGE_EMPTY_TRASH": "Empty trash",
+    "PAGE_SUBSCRIBE": "Subscribe page",
+    "PAGE_UNSUBSCRIBE": "Unsubscribe page",
+    "PAGE_EXPORT": "Export page",
+    "TAG_UPDATE": "Update tags",
+    "IN_APP_NOTIFICATION_ALL_STATUSES_OPEN": "Read all In-App notifications",
+    "COMMENT_CREATE": "Create comment",
+    "COMMENT_UPDATE": "Update comment",
+    "COMMENT_REMOVE": "Delete comment",
+    "SHARE_LINK_CREATE": "Create Share link",
+    "SHARE_LINK_DELETE": "Delete Share link",
+    "SHARE_LINK_DELETE_BY_PAGE": "Remove all shared links on the page",
+    "SHARE_LINK_ALL_DELETE": "Delete all share link",
+    "SHARE_LINK_PAGE_VIEW": "Page view(Share link)",
+    "SHARE_LINK_EXPIRED_PAGE_VIEW": "Page view(Expired share link)",
+    "SHARE_LINK_NOT_FOUND": "Page view (Not found share link)",
+    "ATTACHMENT_ADD": "Add Attachment",
+    "ATTACHMENT_REMOVE": "Delete Attachment",
+    "ACTION_ATTACHMENT_DOWNLOAD": "Download Attachment",
+    "SEARCH_PAGE": "Page Search",
+    "SEARCH_PAGE_VIEW": "Page view(Search results page)",
+    "ADMIN_APP_SETTING_UPDATE": "Update App Settings",
+    "ADMIN_SITE_URL_UPDATE": "Update Site URL Settings",
+    "ADMIN_MAIL_SMTP_UPDATE": "Update E-mail(SMTP) Settings",
+    "ADMIN_MAIL_SES_UPDATE": "Update E-mail(SES) Settings",
+    "ADMIN_MAIL_TEST_SUBMIT" : "Send test mail",
+    "ADMIN_FILE_UPLOAD_CONFIG_UPDATE": "Update File Upload Settings",
+    "ADMIN_PLUGIN_UPDATE": "Update Plugin Settings",
+    "ADMIN_MAINTENANCEMODE_ENABLED": "Enable Maintenance Mode",
+    "ADMIN_MAINTENANCEMODE_DISABLED": "Disabled Maintenance Mode",
+    "ADMIN_SECURITY_SETTINGS_UPDATE": "Update Security Settings",
+    "ADMIN_PERMIT_SHARE_LINK": "Enable Share Link",
+    "ADMIN_REJECT_SHARE_LINK": "Disable Share Link",
+    "ADMIN_AUTH_ID_PASS_ENABLED": "Enable ID/Password auth",
+    "ADMIN_AUTH_ID_PASS_DISABLED": "Disable ID/Password auth",
+    "ADMIN_AUTH_ID_PASS_UPDATE": "Update ID/Password auth settings",
+    "ADMIN_AUTH_LDAP_ENABLED": "Enable LDAP auth",
+    "ADMIN_AUTH_LDAP_DISABLED": "Disable LDAP auth",
+    "ADMIN_AUTH_LDAP_UPDATE": "Update LDAP auth settings",
+    "ADMIN_AUTH_SAML_ENABLED": "Enable SAML auth",
+    "ADMIN_AUTH_SAML_DISABLED": "Disable SAML auth",
+    "ADMIN_AUTH_SAML_UPDATE": "Update SAML auth settings",
+    "ADMIN_AUTH_OIDC_ENABLED": "Enable OIDC auth",
+    "ADMIN_AUTH_OIDC_DISABLED": "Disable OIDC auth",
+    "ADMIN_AUTH_OIDC_UPDATE": "Update OIDC settings",
+    "ADMIN_AUTH_BASIC_ENABLED": "Enable BASIC auth",
+    "ADMIN_AUTH_BASIC_DISABLED": "Disable BASIC auth",
+    "ADMIN_AUTH_BASIC_UPDATE": "Update BASIC auth settings",
+    "ADMIN_AUTH_GOOGLE_ENABLED": "Enable Google auth",
+    "ADMIN_AUTH_GOOGLE_DISABLED": "Disable Google auth",
+    "ADMIN_AUTH_GOOGLE_UPDATE": "Update Google auth settings",
+    "ADMIN_AUTH_GITHUB_ENABLED": "Enable GitHub auth",
+    "ADMIN_AUTH_GITHUB_DISABLED": "Disable GitHub auth",
+    "ADMIN_AUTH_GITHUB_UPDATE": "Update GitHub auth settings",
+    "ADMIN_AUTH_TWITTER_ENABLED": "Enable Twitter auth",
+    "ADMIN_AUTH_TWITTER_DISABLED": "Disable Twitter auth",
+    "ADMIN_AUTH_TWITTER_UPDATE": "Update Twitter auth settings",
+    "ADMIN_MARKDOWN_LINE_BREAK_UPDATE": "Update Link Break settings",
+    "ADMIN_MARKDOWN_INDENT_UPDATE": "Update Indent settings",
+    "ADMIN_MARKDOWN_PRESENTATION_UPDATE": "Update Presentation setting",
+    "ADMIN_MARKDOWN_XSS_UPDATE": "Update prevent XSS settings",
+    "ADMIN_LAYOUT_UPDATE": "Update Layout",
+    "ADMIN_THEME_UPDATE": "Update Theme",
+    "ADMIN_SIDEBAR_UPDATE": "Update Default Sidebar mode",
+    "ADMIN_FUNCTION_UPDATE": "Update Function",
+    "ADMIN_CODE_HIGHLIGHT_UPDATE": "Update Code Highlight",
+    "ADMIN_CUSTOM_TITLE_UPDATE": "Update Custom Title",
+    "ADMIN_CUSTOM_HTML_HEADER_UPDATE": "Update Custom HTML header",
+    "ADMIN_CUSTOM_CSS_UPDATE": "Update Custom CSS",
+    "ADMIN_CUSTOM_SCRIPT_UPDATE": "Update Custom script",
+    "ADMIN_ARCHIVE_DATA_UPLOAD": "Upload Archived Data",
+    "ADMIN_GROWI_DATA_IMPORTED": "Import Archived Data",
+    "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "Discard Archived Data",
+    "ADMIN_ESA_DATA_IMPORTED": "Import from esa.io",
+    "ADMIN_ESA_DATA_UPDATED": "Update esa.io import settings",
+    "ADMIN_CONNECTION_TEST_OF_ESA_DATA": "Test connection to esa",
+    "ADMIN_QIITA_DATA_IMPORTED": "Import from Qiita:Team",
+    "ADMIN_QIITA_DATA_UPDATED": "Update Qiita:Team import settings",
+    "ADMIN_CONNECTION_TEST_OF_QIITA_DATA": "Test connection to Qiita:Team",
+    "ADMIN_ARCHIVE_DATA_CREATE": "Create Archived Data",
+    "ADMIN_ARCHIVE_DATA_DOWNLOAD": "Download Archive Data",
+    "ADMIN_ARCHIVE_DATA_DELETE": "Delete Archive Data",
+    "ADMIN_USER_NOTIFICATION_SETTINGS_ADD": "Add User trigger notification notification settings",
+    "ADMIN_USER_NOTIFICATION_SETTINGS_DELETE": "Delete User trigger notification notification settings",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD": "Add Grobal notification settings",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE": "Update Grobal notification settings",
+    "ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE": "Update Grobal notification permissions",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED": "Enable Grobal notification settings",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED": "Disable Grobal notification settings",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE": "Delete Grobal notification settings",
+    "ADMIN_SLACK_WORKSPACE_CREATE": "Add Slack Workspace",
+    "ADMIN_SLACK_WORKSPACE_DELETE": "Delete Slack Workspace",
+    "ADMIN_SLACK_BOT_TYPE_UPDATE": "Change Slack bot type",
+    "ADMIN_SLACK_BOT_TYPE_DELETE": "Delete Slack bot type",
+    "ADMIN_SLACK_ACCESS_TOKEN_REGENERATE": "Regenerate Slack access token",
+    "ADMIN_SLACK_MAKE_APP_PRIMARY": "Make the Slack bot primary",
+    "ADMIN_SLACK_PERMISSION_UPDATE": "Update Slack bot permissions",
+    "ADMIN_SLACK_PROXY_URI_UPDATE": "Update Proxy URL for Custom bot with proxy",
+    "ADMIN_SLACK_RELATION_TEST": "Test connection to slack bot",
+    "ADMIN_SLACK_WITHOUT_PROXY_SETTINGS_UPDATE": "Update Slack bot without proxy settings",
+    "ADMIN_SLACK_WITHOUT_PROXY_PERMISSION_UPDATE": "Update Slack bot without proxy permissions",
+    "ADMIN_SLACK_WITHOUT_PROXY_TEST": "Test connection to Slack bot without proxy",
+    "ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE": "Update Slack Incoming Webhooks configuration",
+    "ADMIN_USERS_INVITE": "User Invitation",
+    "ADMIN_USERS_PASSWORD_RESET": "Reset user password",
+    "ADMIN_USERS_ACTIVATE": "Activate user",
+    "ADMIN_USERS_DEACTIVATE": "Deactivate user",
+    "ADMIN_USERS_GIVE_ADMIN": "Give admin access",
+    "ADMIN_USERS_REMOVE_ADMIN": "Remove admin access",
+    "ADMIN_USERS_SEND_INVITATION_EMAIL": "Resend invitation email",
+    "ADMIN_USERS_REMOVE": "Remove user",
+    "ADMIN_USER_GROUP_CREATE": "Create User Group",
+    "ADMIN_USER_GROUP_UPDATE": "Update User Group",
+    "ADMIN_USER_GROUP_DELETE": "Delete User Group",
+    "ADMIN_USER_GROUP_ADD_USER": "Add User to User Group",
+    "ADMIN_SEARCH_CONNECTION": "Attempting to reconnect to Elasticsearch",
+    "ADMIN_SEARCH_INDICES_NORMALIZE": "Normalize of Elasticsearch indexes",
+    "ADMIN_SEARCH_INDICES_REBUILD": "Rebuild Elasticsearch indexes"
   }
 }

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

@@ -164,6 +164,7 @@
   "No bookmarks yet": "No bookmarks yet",
   "add_bookmark": "Add to Bookmarks",
   "remove_bookmark": "Remove from Bookmarks",
+  "wide_view": "Wide View",
   "Recent Created": "Recent Created",
   "Recent Changes": "Recent Changes",
   "Page Tree": "Page Tree",
@@ -537,6 +538,7 @@
     "create_failed": "Failed to create {{target}}",
     "update_successed": "Succeeded to update {{target}}",
     "update_failed": "Failed to update {{target}}",
+    "file_upload_failed": "File upload failed.",
     "initialize_successed": "Succeeded to initialize {{target}}",
     "give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin",
@@ -1082,11 +1084,18 @@
     "belonging_to_no_group": "Could not find the groups you belong to.",
     "manage_user_groups": "Manage user groups"
   },
+  "crop_image_modal": {
+    "image_crop": "Image Crop",
+    "crop": "Crop",
+    "reset": "Reset",
+    "cancel": "Cancel"
+  },
   "fix_page_grant": {
     "modal": {
       "no_grant_available": "The list of selectable permissions could not be found. Please modify the permissions on the parent page first and try again.",
       "need_to_fix_grant": "The permissions associated with this page must be modified in order to use the functionality correctly. <br> Please select from the options below to make the change.",
       "grant_label": {
+        "public": "Public",
         "isForbidden": "Authority not allowed to view",
         "currentPageGrantLabel": "Authorization for this page: ",
         "parentPageGrantLabel": "Authority of parent page: ",

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

@@ -207,7 +207,13 @@
     "ctrl_space": "Ctrl+Space でコード補完",
     "custom_script": "カスタムスクリプト",
     "write_java": "システム全体に適用されるJavaScriptを記述できます。",
-    "reflect_change": "変更の反映はページの更新が必要です。"
+    "reflect_change": "変更の反映はページの更新が必要です。",
+    "custom_logo": "カスタムロゴ",
+    "default_logo": "デフォルトのロゴ",
+    "upload_logo": "ロゴをアップロード",
+    "current_logo": "現在のロゴ",
+    "upload_new_logo": "新しいロゴをアップロードする",
+    "delete_logo": "ロゴを削除"
   },
   "export_management": {
     "exporting_collection_list": "エクスポート中のコレクション",
@@ -522,6 +528,7 @@
     }
   },
   "audit_log_management": {
+    "user": "ユーザー",
     "username": "ユーザー名",
     "date": "日付",
     "action": "アクション",
@@ -529,12 +536,183 @@
     "url": "URL",
     "settings": "設定",
     "return": "戻る",
+    "clear": "クリア",
     "activity_expiration_date": "監査ログの有効期限",
     "activity_expiration_date_explain": "作成された監査ログは、作成時間から環境変数に設定した秒数後に自動的に削除されます",
     "fixed_by_env_var": "環境変数により固定されています <code>{{key}}={{value}}</code>.",
     "available_action_list": "検索 / 表示 可能なアクション一覧",
-    "available_action_list_explain": "監査ログで 検索 / 表示 可能なアクション一覧です",
+    "available_action_list_explain": "現在の設定で検索 / 表示 可能なアクション一覧です",
     "action_list": "アクション一覧",
-    "disable_mode_explain": "現在、監査ログは無効になっています。有効にする場合は環境変数 <code>AUDIT_LOG_ENABLED</code> を true に設定してください。"
+    "disable_mode_explain": "現在、監査ログは無効になっています。有効にする場合は環境変数 <code>AUDIT_LOG_ENABLED</code> を true に設定してください。",
+    "docs_url": {
+      "log_type": "https://docs.growi.org/ja/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
+    }
+  },
+  "audit_log_action_category": {
+    "Page": "ページ",
+    "Comment": "コメント",
+    "Tag": "タグ",
+    "Attachment": "添付ファイル",
+    "ShareLink": "シェアリンク",
+    "Search": "検索",
+    "User": "ユーザー",
+    "Admin": "管理者ユーザー"
+  },
+  "audit_log_action": {
+    "USER_REGISTRATION_SUCCESS": "ユーザー作成",
+    "USER_LOGIN_WITH_LOCAL": "ID/Password 認証でログイン",
+    "USER_LOGIN_WITH_LDAP": "LDAP 認証でログイン",
+    "USER_LOGIN_WITH_GOOGLE": "Google 認証でログイン",
+    "USER_LOGIN_WITH_GITHUB": "GitHub 認証でログイン",
+    "USER_LOGIN_WITH_TWITTER": "Twitter 認証でログイン",
+    "USER_LOGIN_WITH_OIDC": "OIDC 認証でログイン",
+    "USER_LOGIN_WITH_SAML": "SAML 認証でログイン",
+    "USER_LOGIN_WITH_BASIC": "BASIC 認証でログイン",
+    "USER_LOGIN_FAILURE": "ログイン失敗",
+    "USER_LOGOUT": "ログアウト",
+    "USER_FOGOT_PASSWORD": "パスワードリセットのリクエスト",
+    "USER_RESET_PASSWORD": "パスワードのリセット",
+    "USER_PERSONAL_SETTINGS_UPDATE": "ユーザーの基本情報の更新",
+    "USER_IMAGE_TYPE_UPDATE": "プロフィール画像の変更",
+    "USER_LDAP_ACCOUNT_ASSOCIATE": "LDAP アカウントの追加",
+    "USER_LDAP_ACCOUNT_DISCONNECT": "LDAP アカウントの切断",
+    "USER_PASSWORD_UPDATE": "パスワードの変更",
+    "USER_API_TOKEN_UPDATE": "API トークンの更新",
+    "USER_EDITOR_SETTINGS_UPDATE": "エディター設定の更新",
+    "USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE": "アプリ内通知設定の更新",
+    "PAGE_VIEW": "ページ閲覧",
+    "PAGE_USER_HOME_VIEW": "ページ閲覧(ユーザーホーム)",
+    "PAGE_FORBIDDEN": "ページ閲覧(fobiddenページ)",
+    "PAGE_NOT_FOUND": "ページ閲覧(Not Found ページ)",
+    "PAGE_NOT_CREATABLE": "ページ閲覧(Not Creatable ページ)",
+    "PAGE_LIKE": "いいね",
+    "PAGE_UNLIKE": "いいねの解除",
+    "PAGE_BOOKMARK": "ブックマーク",
+    "PAGE_UNBOOKMARK": "ブックマークの解除",
+    "PAGE_CREATE": "ページの作成",
+    "PAGE_UPDATE": "ページの更新",
+    "PAGE_RENAME": "ページのリネーム",
+    "PAGE_DUPLICATE": "ページの複製",
+    "PAGE_DELETE": "ページの削除",
+    "PAGE_DELETE_COMPLETELY": "ページの完全削除",
+    "PAGE_REVERT": "ページを元に戻す",
+    "PAGE_EMPTY_TRASH": "ゴミ箱を空にする",
+    "PAGE_SUBSCRIBE": "ページをサブスクライブ",
+    "PAGE_UNSUBSCRIBE": "ページをアンサブスクライブ",
+    "PAGE_EXPORT": "マークダウン形式でページをエクスポート",
+    "TAG_UPDATE": "タグの更新",
+    "IN_APP_NOTIFICATION_ALL_STATUSES_OPEN": "アプリ内通知を全て既読",
+    "COMMENT_CREATE": "コメントの作成",
+    "COMMENT_UPDATE": "コメントの更新",
+    "COMMENT_REMOVE": "コメントの削除",
+    "SHARE_LINK_CREATE": "共有リンクの作成",
+    "SHARE_LINK_DELETE": "共有リンクの削除",
+    "SHARE_LINK_DELETE_BY_PAGE": "ページ内の共有リンクを全て削除",
+    "SHARE_LINK_ALL_DELETE": "共有リンクを全て削除",
+    "SHARE_LINK_PAGE_VIEW": "ページ閲覧(共有リンク)",
+    "SHARE_LINK_EXPIRED_PAGE_VIEW": "ページ閲覧(期限切れの共有リンク)",
+    "SHARE_LINK_NOT_FOUND": "ページ閲覧(存在しない共有リンク)",
+    "ATTACHMENT_ADD": "添付データの追加",
+    "ATTACHMENT_REMOVE": "添付データの削除",
+    "ACTION_ATTACHMENT_DOWNLOAD": "添付データのダウンロード",
+    "SEARCH_PAGE": "ページの検索",
+    "SEARCH_PAGE_VIEW": "ページ閲覧(検索結果ページ)",
+    "ADMIN_APP_SETTING_UPDATE": "アプリ設定の更新",
+    "ADMIN_SITE_URL_UPDATE": "サイトURL設定の更新",
+    "ADMIN_MAIL_SMTP_UPDATE": "メール設定(SMTP)の更新",
+    "ADMIN_MAIL_SES_UPDATE": "メール設定(SES)の更新",
+    "ADMIN_MAIL_TEST_SUBMIT" : "テストメールの送信",
+    "ADMIN_FILE_UPLOAD_CONFIG_UPDATE": "ファイルアップロード設定の更新",
+    "ADMIN_PLUGIN_UPDATE": "プラグイン設定の更新",
+    "ADMIN_MAINTENANCEMODE_ENABLED": "メンテナンスモードの開始",
+    "ADMIN_MAINTENANCEMODE_DISABLED": "メンテナンスモードの終了",
+    "ADMIN_SECURITY_SETTINGS_UPDATE": "セキュリティ設定の更新",
+    "ADMIN_PERMIT_SHARE_LINK": "共有リンクの有効化",
+    "ADMIN_REJECT_SHARE_LINK": "共有リンクの無効化",
+    "ADMIN_AUTH_ID_PASS_ENABLED": "ID/Password 認証を有効",
+    "ADMIN_AUTH_ID_PASS_DISABLED": "ID/Password 認証を無効",
+    "ADMIN_AUTH_ID_PASS_UPDATE": "ID/Password 認証設定を更新",
+    "ADMIN_AUTH_LDAP_ENABLED": "LDAP 認証を有効",
+    "ADMIN_AUTH_LDAP_DISABLED": "LDAP 認証を無効",
+    "ADMIN_AUTH_LDAP_UPDATE": "LDAP 認証設定を更新",
+    "ADMIN_AUTH_SAML_ENABLED": "SAML 認証を有効",
+    "ADMIN_AUTH_SAML_DISABLED": "SAML 認証を無効",
+    "ADMIN_AUTH_SAML_UPDATE": "SAML 認証設定を更新",
+    "ADMIN_AUTH_OIDC_ENABLED": "OIDC 認証を有効",
+    "ADMIN_AUTH_OIDC_DISABLED": "OIDC 認証を無効",
+    "ADMIN_AUTH_OIDC_UPDATE": "OIDC 認証設定の更新",
+    "ADMIN_AUTH_BASIC_ENABLED": "BASIC 認証の有効",
+    "ADMIN_AUTH_BASIC_DISABLED": "BASIC 認証の無効",
+    "ADMIN_AUTH_BASIC_UPDATE": "BASIC 認証設定の更新",
+    "ADMIN_AUTH_GOOGLE_ENABLED": "Google 認証の有効",
+    "ADMIN_AUTH_GOOGLE_DISABLED": "Google 認証の無効",
+    "ADMIN_AUTH_GOOGLE_UPDATE": "Google 認証設定の更新",
+    "ADMIN_AUTH_GITHUB_ENABLED": "GitHub 認証の有効",
+    "ADMIN_AUTH_GITHUB_DISABLED": "GitHub 認証の無効",
+    "ADMIN_AUTH_GITHUB_UPDATE": "GitHub 認証設定の更新",
+    "ADMIN_AUTH_TWITTER_ENABLED": "Twitter 認証の有効",
+    "ADMIN_AUTH_TWITTER_DISABLED": "Twitter 認証の無効",
+    "ADMIN_AUTH_TWITTER_UPDATE": "Twitter 認証設定の更新",
+    "ADMIN_MARKDOWN_LINE_BREAK_UPDATE": "Line Break 設定の更新",
+    "ADMIN_MARKDOWN_INDENT_UPDATE": "インデント設定の更新",
+    "ADMIN_MARKDOWN_PRESENTATION_UPDATE": "プレゼンテーション設定の更新",
+    "ADMIN_MARKDOWN_XSS_UPDATE": "XSS 対策設定の更新",
+    "ADMIN_LAYOUT_UPDATE": "レイアウト設定の更新",
+    "ADMIN_THEME_UPDATE": "テーマ設定の更新",
+    "ADMIN_SIDEBAR_UPDATE": "デフォルトのサイドバーモードの設定の更新",
+    "ADMIN_FUNCTION_UPDATE": "機能設定の更新",
+    "ADMIN_CODE_HIGHLIGHT_UPDATE": "コードハイライト設定の更新",
+    "ADMIN_CUSTOM_TITLE_UPDATE": "カスタムタイトル設定の更新",
+    "ADMIN_CUSTOM_HTML_HEADER_UPDATE": "カスタム HTML Header 設定の更新",
+    "ADMIN_CUSTOM_CSS_UPDATE": "カスタム CSS 設定の更新",
+    "ADMIN_CUSTOM_SCRIPT_UPDATE": "カスタムスクリプト設定の更新",
+    "ADMIN_ARCHIVE_DATA_UPLOAD": "アーカイブデータのアップロード",
+    "ADMIN_GROWI_DATA_IMPORTED": "アーカイブデータのインポート",
+    "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "アーカイブデータの破棄",
+    "ADMIN_ESA_DATA_IMPORTED": "esa.io からインポート",
+    "ADMIN_ESA_DATA_UPDATED": "esa.io のインポート設定の更新",
+    "ADMIN_CONNECTION_TEST_OF_ESA_DATA": "esa.io の接続テスト",
+    "ADMIN_QIITA_DATA_IMPORTED": "Qiita:Team からのインポート",
+    "ADMIN_QIITA_DATA_UPDATED": "Qiita:Team のインポート設定の更新",
+    "ADMIN_CONNECTION_TEST_OF_QIITA_DATA": "Qiita:Team の接続テスト",
+    "ADMIN_ARCHIVE_DATA_CREATE": "アーカイブデータの作成",
+    "ADMIN_ARCHIVE_DATA_DOWNLOAD": "アーカイブデータのダウンロード",
+    "ADMIN_ARCHIVE_DATA_DELETE": "アーカイブデータの削除",
+    "ADMIN_USER_NOTIFICATION_SETTINGS_ADD": "User trigger notification の通知設定の追加",
+    "ADMIN_USER_NOTIFICATION_SETTINGS_DELETE": "User trigger notification の通知設定の削除",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD": "Grobal notification の設定の追加",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE": "Grobal notification の設定の更新",
+    "ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE": "Grobal notification の権限の更新",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED": "Grobal notification の設定の有効",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED": "Grobal notification の設定の無効",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE": "Grobal notification の設定の削除",
+    "ADMIN_SLACK_WORKSPACE_CREATE": "Slack ワークスペースの追加",
+    "ADMIN_SLACK_WORKSPACE_DELETE": "Slack ワークスペースの削除",
+    "ADMIN_SLACK_BOT_TYPE_UPDATE": "Slack bot タイプの変更",
+    "ADMIN_SLACK_BOT_TYPE_DELETE": "Slack bot タイプの削除",
+    "ADMIN_SLACK_ACCESS_TOKEN_REGENERATE": "Slack アクセストークンの再発行",
+    "ADMIN_SLACK_MAKE_APP_PRIMARY": "Slack bot をプライマリーにする",
+    "ADMIN_SLACK_PERMISSION_UPDATE": "Slack bot の権限の更新",
+    "ADMIN_SLACK_PROXY_URI_UPDATE": "Custom bot with proxy の Proxy URL の更新",
+    "ADMIN_SLACK_RELATION_TEST": "Slack bot の接続テスト",
+    "ADMIN_SLACK_WITHOUT_PROXY_SETTINGS_UPDATE": "Slack bot without proxy の設定の更新",
+    "ADMIN_SLACK_WITHOUT_PROXY_PERMISSION_UPDATE": "Slack bot without proxy の権限の更新",
+    "ADMIN_SLACK_WITHOUT_PROXY_TEST": "Slack bot without proxy の接続テスト",
+    "ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE": "Slack Incoming Webhooks の設定の更新",
+    "ADMIN_USERS_INVITE": "ユーザーの招待",
+    "ADMIN_USERS_PASSWORD_RESET": "ユーザーのパスワードをリセット",
+    "ADMIN_USERS_ACTIVATE": "ユーザーを承認する",
+    "ADMIN_USERS_DEACTIVATE": "ユーザーを停止する",
+    "ADMIN_USERS_GIVE_ADMIN": "管理者にする",
+    "ADMIN_USERS_REMOVE_ADMIN": "管理者から外す",
+    "ADMIN_USERS_SEND_INVITATION_EMAIL": "招待メールの再送信",
+    "ADMIN_USERS_REMOVE": "ユーザーの削除",
+    "ADMIN_USER_GROUP_CREATE": "ユーザーグループの作成",
+    "ADMIN_USER_GROUP_UPDATE": "ユーザーグループの更新",
+    "ADMIN_USER_GROUP_DELETE": "ユーザーグループの削除",
+    "ADMIN_USER_GROUP_ADD_USER": "ユーザーグループにユーザーを追加",
+    "ADMIN_SEARCH_CONNECTION": "Elasticsearch の再接続の試行",
+    "ADMIN_SEARCH_INDICES_NORMALIZE": "Elasticsearch のインデックスの正規化",
+    "ADMIN_SEARCH_INDICES_REBUILD": "Elasticsearch のインデックスのリビルド"
   }
 }

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

@@ -166,6 +166,7 @@
   "No bookmarks yet": "No bookmarks yet",
   "add_bookmark": "ブックマークに追加",
   "remove_bookmark": "ブックマークから削除",
+  "wide_view": "ワイドビュー",
   "Recent Created": "最新の作成",
   "Recent Changes": "最新の変更",
   "Page Tree": "ページツリー",
@@ -537,6 +538,7 @@
     "create_failed": "{{target}}の作成に失敗しました",
     "update_successed": "{{target}}を更新しました",
     "update_failed": "{{target}}の更新に失敗しました",
+    "file_upload_failed": "ファイルのアップロードに失敗しました",
     "initialize_successed": "{{target}}を初期化しました",
     "give_user_admin": "{{username}}を管理者に設定しました",
     "remove_user_admin": "{{username}}を管理者から外しました",
@@ -1075,11 +1077,18 @@
     "belonging_to_no_group": "所属しているグループが見つかりませんでした。",
     "manage_user_groups": "グループ管理"
   },
+  "crop_image_modal": {
+    "image_crop": "画像の切り抜き",
+    "crop": "トリミング",
+    "reset": "リセット",
+    "cancel": "キャンセル"
+  },
   "fix_page_grant": {
     "modal": {
       "no_grant_available": "選択可能な権限のリストが見つかりませんでした。まず親ページの権限を修正したのちに再試行してください。",
       "need_to_fix_grant": "正しく機能を使用するためにはこのページに紐づく権限を修正する必要があります。 <br> 下記の選択肢から選んで変更してください。",
       "grant_label": {
+        "public": "公開",
         "isForbidden": "権限の閲覧が許可されていません",
         "currentPageGrantLabel": "このページの権限: ",
         "parentPageGrantLabel": "親のページの権限: ",

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

@@ -217,7 +217,13 @@
     "ctrl_space": "Ctrl+Space 自动完成",
     "custom_script": "定制纸条",
     "write_java": "您可以编写应用于整个系统的Javascript。",
-    "reflect_change": "您需要重新加载页面以反映更改。"
+    "reflect_change": "您需要重新加载页面以反映更改。",
+    "custom_logo": "自定义徽标",
+    "default_logo": "默认徽标",
+    "upload_logo": "上传徽标",
+    "current_logo": "当前标志",
+    "upload_new_logo": "上传新徽标",
+    "delete_logo": "删除徽标"
   },
   "importer_management": {
     "beta_warning": "这个函数是Beta。",
@@ -532,6 +538,7 @@
     }
   },
   "audit_log_management": {
+    "user": "用户",
     "username": "帐号",
     "date": "日期",
     "action": "行动",
@@ -539,12 +546,184 @@
     "url": "URL",
     "settings": "设置",
     "return": "返回",
+    "clear": "清除",
     "activity_expiration_date": "审计日志的到期日",
     "activity_expiration_date_explain": "创建的审计日志会在环境变量中设置的从创建时间算起的秒数后自动删除",
     "fixed_by_env_var": "这是由env var 修复的 <code>{{key}}={{value}}</code>.",
     "available_action_list": "搜索/查看 所有可用的行动",
-    "available_action_list_explain": "可以在审计日志中 搜索/查看 的行动列表",
+    "available_action_list_explain": "在当前配置中可以搜索/查看的行动列表",
     "action_list": "行动清单",
-    "disable_mode_explain": "审计日志当前已禁用。 要启用它,请将环境变量 <code>AUDIT_LOG_ENABLED</code> 设置为 true。"
+    "disable_mode_explain": "审计日志当前已禁用。 要启用它,请将环境变量 <code>AUDIT_LOG_ENABLED</code> 设置为 true。",
+    "docs_url": {
+      "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
+    }
+
+  },
+  "audit_log_action_category": {
+    "Page": "页面",
+    "Comment": "评论",
+    "Tag": "标签",
+    "Attachment": "附件",
+    "ShareLink": "分享链接",
+    "Search": "搜索",
+    "User": "用户",
+    "Admin": "管理"
+  },
+  "audit_log_action": {
+    "USER_REGISTRATION_SUCCESS": "用户创建",
+    "USER_LOGIN_WITH_LOCAL": "用ID/密码登录",
+    "USER_LOGIN_WITH_LDAP": "使用 LDAP 登录",
+    "USER_LOGIN_WITH_GOOGLE": "用谷歌登录",
+    "USER_LOGIN_WITH_GITHUB": "使用 GitHub 登录",
+    "USER_LOGIN_WITH_TWITTER": "使用 Twitter 登录",
+    "USER_LOGIN_WITH_OIDC": "使用 OIDC 登录",
+    "USER_LOGIN_WITH_SAML": "使用 SAML 登录",
+    "USER_LOGIN_WITH_BASIC": "使用 BASIC 登录",
+    "USER_LOGIN_FAILURE": "登录失败",
+    "USER_LOGOUT": "注销",
+    "USER_FOGOT_PASSWORD": "要求重置密码",
+    "USER_RESET_PASSWORD": "重置密码",
+    "USER_PERSONAL_SETTINGS_UPDATE": "用户个人设置更新",
+    "USER_IMAGE_TYPE_UPDATE": "用户图片类型更新",
+    "USER_LDAP_ACCOUNT_ASSOCIATE": "LDAP 帐户关联",
+    "USER_LDAP_ACCOUNT_DISCONNECT": "LDAP 账户断开连接",
+    "USER_PASSWORD_UPDATE": "密码更新",
+    "USER_API_TOKEN_UPDATE": "API 令牌更新",
+    "USER_EDITOR_SETTINGS_UPDATE": "编辑器设置更新",
+    "USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE": "应用内通知设置更新",
+    "PAGE_VIEW": "页面浏览量",
+    "PAGE_USER_HOME_VIEW": "页面浏览量(用户主页)",
+    "PAGE_FORBIDDEN": "页面浏览量(禁止页面)",
+    "PAGE_NOT_FOUND": "页面查看(未找到页面)",
+    "PAGE_NOT_CREATABLE": "页面浏览量(不可创建页面)",
+    "PAGE_LIKE": "喜欢",
+    "PAGE_UNLIKE": "不喜欢",
+    "PAGE_BOOKMARK": "书签",
+    "PAGE_UNBOOKMARK": "取消书签",
+    "PAGE_CREATE": "创建页面",
+    "PAGE_UPDATE": "更新页面",
+    "PAGE_RENAME": "重命名页面",
+    "PAGE_DUPLICATE": "重复页面",
+    "PAGE_DELETE": "删除页面",
+    "PAGE_DELETE_COMPLETELY": "彻底删除页面",
+    "PAGE_REVERT": "还原页面",
+    "PAGE_EMPTY_TRASH": "清空垃圾箱",
+    "PAGE_SUBSCRIBE": "订阅页面",
+    "PAGE_UNSUBSCRIBE": "退订页面",
+    "PAGE_EXPORT": "导出页面",
+    "TAG_UPDATE": "更新标签",
+    "IN_APP_NOTIFICATION_ALL_STATUSES_OPEN": "读取所有应用内通知",
+    "COMMENT_CREATE": "创建评论",
+    "COMMENT_UPDATE": "更新评论",
+    "COMMENT_REMOVE": "删除评论",
+    "SHARE_LINK_CREATE": "创建分享链接",
+    "SHARE_LINK_DELETE": "删除分享链接",
+    "SHARE_LINK_DELETE_BY_PAGE": "删除页面上的所有共享链接",
+    "SHARE_LINK_ALL_DELETE": "删除所有分享链接",
+    "SHARE_LINK_PAGE_VIEW": "页面浏览量(分享链接)",
+    "SHARE_LINK_EXPIRED_PAGE_VIEW": "页面浏览量(已过期的分享链接)",
+    "SHARE_LINK_NOT_FOUND": "页面浏览量(未找到分享链接)",
+    "ATTACHMENT_ADD": "添加附件",
+    "ATTACHMENT_REMOVE": "删除附件",
+    "ACTION_ATTACHMENT_DOWNLOAD": "下载附件",
+    "SEARCH_PAGE": "页面搜索",
+    "SEARCH_PAGE_VIEW": "页面浏览量(搜索结果页面)",
+    "ADMIN_APP_SETTING_UPDATE": "更新应用设置",
+    "ADMIN_SITE_URL_UPDATE": "更新站点 URL 设置",
+    "ADMIN_MAIL_SMTP_UPDATE": "更新电子邮件(SMTP)设置",
+    "ADMIN_MAIL_SES_UPDATE": "更新电子邮件(SES)设置",
+    "ADMIN_MAIL_TEST_SUBMIT" : "发送测试邮件",
+    "ADMIN_FILE_UPLOAD_CONFIG_UPDATE": "更新文件上传设置",
+    "ADMIN_PLUGIN_UPDATE": "更新插件设置",
+    "ADMIN_MAINTENANCEMODE_ENABLED": "启用维护模式",
+    "ADMIN_MAINTENANCEMODE_DISABLED": "禁用维护模式",
+    "ADMIN_SECURITY_SETTINGS_UPDATE": "更新安全设置",
+    "ADMIN_PERMIT_SHARE_LINK": "启用分享链接",
+    "ADMIN_REJECT_SHARE_LINK": "禁用分享链接",
+    "ADMIN_AUTH_ID_PASS_ENABLED": "启用 ID/密码验证",
+    "ADMIN_AUTH_ID_PASS_DISABLED": "禁用 ID/密码验证",
+    "ADMIN_AUTH_ID_PASS_UPDATE": "更新 ID/密码验证设置",
+    "ADMIN_AUTH_LDAP_ENABLED": "启用 LDAP 身份验证",
+    "ADMIN_AUTH_LDAP_DISABLED": "禁用 LDAP 身份验证",
+    "ADMIN_AUTH_LDAP_UPDATE": "更新 LDAP 身份验证设置",
+    "ADMIN_AUTH_SAML_ENABLED": "启用 SAML 身份验证",
+    "ADMIN_AUTH_SAML_DISABLED": "禁用 SAML 身份验证",
+    "ADMIN_AUTH_SAML_UPDATE": "更新 SAML 身份验证设置",
+    "ADMIN_AUTH_OIDC_ENABLED": "启用 OIDC 身份验证",
+    "ADMIN_AUTH_OIDC_DISABLED": "禁用 OIDC 身份验证",
+    "ADMIN_AUTH_OIDC_UPDATE": "更新 OIDC 设置",
+    "ADMIN_AUTH_BASIC_ENABLED": "启用基本身份验证",
+    "ADMIN_AUTH_BASIC_DISABLED": "禁用基本身份验证",
+    "ADMIN_AUTH_BASIC_UPDATE": "更新基本认证设置",
+    "ADMIN_AUTH_GOOGLE_ENABLED": "启用谷歌身份验证",
+    "ADMIN_AUTH_GOOGLE_DISABLED": "禁用谷歌身份验证",
+    "ADMIN_AUTH_GOOGLE_UPDATE": "更新谷歌授权设置",
+    "ADMIN_AUTH_GITHUB_ENABLED": "启用 GitHub 身份验证",
+    "ADMIN_AUTH_GITHUB_DISABLED": "禁用 GitHub 身份验证",
+    "ADMIN_AUTH_GITHUB_UPDATE": "更新 GitHub 授权设置",
+    "ADMIN_AUTH_TWITTER_ENABLED": "启用 Twitter 身份验证",
+    "ADMIN_AUTH_TWITTER_DISABLED": "禁用 Twitter 身份验证",
+    "ADMIN_AUTH_TWITTER_UPDATE": "更新 Twitter 授权设置",
+    "ADMIN_MARKDOWN_LINE_BREAK_UPDATE": "更新链接中断设置",
+    "ADMIN_MARKDOWN_INDENT_UPDATE": "更新缩进设置",
+    "ADMIN_MARKDOWN_PRESENTATION_UPDATE": "更新演示设置",
+    "ADMIN_MARKDOWN_XSS_UPDATE": "更新阻止 XSS 设置",
+    "ADMIN_LAYOUT_UPDATE": "更新布局",
+    "ADMIN_THEME_UPDATE": "更新主题",
+    "ADMIN_SIDEBAR_UPDATE": "更新默认的侧边栏模式",
+    "ADMIN_FUNCTION_UPDATE": "更新函数",
+    "ADMIN_CODE_HIGHLIGHT_UPDATE": "更新代码高亮",
+    "ADMIN_CUSTOM_TITLE_UPDATE": "更新自定义标题",
+    "ADMIN_CUSTOM_HTML_HEADER_UPDATE": "更新自定义 HTML 标头",
+    "ADMIN_CUSTOM_CSS_UPDATE": "更新自定义 CSS",
+    "ADMIN_CUSTOM_SCRIPT_UPDATE": "更新自定义脚本",
+    "ADMIN_ARCHIVE_DATA_UPLOAD": "上传存档数据",
+    "ADMIN_GROWI_DATA_IMPORTED": "导入存档数据",
+    "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "丢弃存档数据",
+    "ADMIN_ESA_DATA_IMPORTED": "从 esa.io 导入",
+    "ADMIN_ESA_DATA_UPDATED": "更新 esa.io 导入设置",
+    "ADMIN_CONNECTION_TEST_OF_ESA_DATA": "测试与 esa 的连接",
+    "ADMIN_QIITA_DATA_IMPORTED": "从 Qiita:Team 导入",
+    "ADMIN_QIITA_DATA_UPDATED": "更新 Qiita:团队导入设置",
+    "ADMIN_CONNECTION_TEST_OF_QIITA_DATA": "测试与 Qiita:Team 的连接",
+    "ADMIN_ARCHIVE_DATA_CREATE": "创建归档数据",
+    "ADMIN_ARCHIVE_DATA_DOWNLOAD": "下载存档数据",
+    "ADMIN_ARCHIVE_DATA_DELETE": "删除存档数据",
+    "ADMIN_USER_NOTIFICATION_SETTINGS_ADD": "添加用户触发通知通知设置",
+    "ADMIN_USER_NOTIFICATION_SETTINGS_DELETE": "删除用户触发通知通知设置",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD": "添加全局通知设置",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE": "更新 Grobal 通知设置",
+    "ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE": "更新 Grobal 通知权限",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED": "启用 Grobal 通知设置",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED": "禁用 Grobal 通知设置",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE": "删除 Grobal 通知设置",
+    "ADMIN_SLACK_WORKSPACE_CREATE": "添加松弛工作区",
+    "ADMIN_SLACK_WORKSPACE_DELETE": "删除 Slack 工作区",
+    "ADMIN_SLACK_BOT_TYPE_UPDATE": "更改 Slack 机器人类型",
+    "ADMIN_SLACK_BOT_TYPE_DELETE": "删除 Slack 机器人类型",
+    "ADMIN_SLACK_ACCESS_TOKEN_REGENERATE": "重新生成 Slack 访问令牌",
+    "ADMIN_SLACK_MAKE_APP_PRIMARY": "将 Slack 机器人设为主要",
+    "ADMIN_SLACK_PERMISSION_UPDATE": "更新 Slack 机器人权限",
+    "ADMIN_SLACK_PROXY_URI_UPDATE": "使用代理更新自定义机器人的代理 URL",
+    "ADMIN_SLACK_RELATION_TEST": "测试与 slack 机器人的连接",
+    "ADMIN_SLACK_WITHOUT_PROXY_SETTINGS_UPDATE": "在没有代理设置的情况下更新 Slack 机器人",
+    "ADMIN_SLACK_WITHOUT_PROXY_PERMISSION_UPDATE": "更新没有代理权限的 Slack 机器人",
+    "ADMIN_SLACK_WITHOUT_PROXY_TEST": "在没有代理的情况下测试与 Slack 机器人的连接",
+    "ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE": "更新 Slack Incoming Webhooks 配置",
+    "ADMIN_USERS_INVITE": "用户邀请",
+    "ADMIN_USERS_PASSWORD_RESET": "重置用户密码",
+    "ADMIN_USERS_ACTIVATE": "激活用户",
+    "ADMIN_USERS_DEACTIVATE": "停用用户",
+    "ADMIN_USERS_GIVE_ADMIN": "授予管理员访问权限",
+    "ADMIN_USERS_REMOVE_ADMIN": "删除管理员访问权限",
+    "ADMIN_USERS_SEND_INVITATION_EMAIL": "重发邀请函",
+    "ADMIN_USERS_REMOVE": "删除用户",
+    "ADMIN_USER_GROUP_CREATE": "创建用户组",
+    "ADMIN_USER_GROUP_UPDATE": "更新用户组",
+    "ADMIN_USER_GROUP_DELETE": "删除用户组",
+    "ADMIN_USER_GROUP_ADD_USER": "添加用户到用户组",
+    "ADMIN_SEARCH_CONNECTION": "重试Elasticsearch连接",
+    "ADMIN_SEARCH_INDICES_NORMALIZE": "试图重新连接Elasticsearch",
+    "ADMIN_SEARCH_INDICES_REBUILD": "重建 Elasticsearch 索引"
   }
 }

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

@@ -172,6 +172,7 @@
   "No bookmarks yet": "暂无书签",
   "add_bookmark": "添加到书签",
   "remove_bookmark": "从书签中删除",
+  "wide_view": "视野开阔",
 	"Recent Created": "最新创建",
   "Recent Changes": "最新修改",
   "Page Tree": "页面树",
@@ -515,6 +516,7 @@
     "create_failed": "Failed to create {{target}}",
 		"update_successed": "Succeeded to update {{target}}",
     "update_failed": "Failed to update {{target}}",
+    "file_upload_failed": "文件上传失败",
     "initialize_successed": "Succeeded to initialize {{target}}",
 		"give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin ",
@@ -1085,11 +1087,18 @@
     "belonging_to_no_group": "无法找到你所属的团体。",
     "manage_user_groups": "管理用户组"
   },
+  "crop_image_modal": {
+    "image_crop": "图像裁剪",
+    "crop": "修剪",
+    "reset": "重启",
+    "cancel": "取消"
+  },
   "fix_page_grant": {
     "modal": {
       "no_grant_available": "无法找到可选择的权限列表。 请先修改父页的权限,然后再试一次。",
       "need_to_fix_grant": "为了正确使用该功能,需要修改与该页面相关的权限。 <br> 请从以下选项中选择进行更改。",
       "grant_label": {
+        "public": "向公众提供",
         "isForbidden": "无权查看的机构",
         "currentPageGrantLabel": "本页的权限: ",
         "parentPageGrantLabel": "父页的权限: ",

+ 1 - 0
packages/app/regconfig.json

@@ -13,6 +13,7 @@
     "reg-notify-github-plugin": {
       "prCommentBehavior": "new",
       "setCommitStatus": false,
+      "shortDescription": true,
       "clientId": "$REG_NOTIFY_GITHUB_PLUGIN_CLIENTID"
     },
     "reg-notify-slack-plugin": {

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

@@ -9,7 +9,6 @@ import PutbackPageModal from '~/components/PutbackPageModal';
 import ShortcutsModal from '~/components/ShortcutsModal';
 import SystemVersion from '~/components/SystemVersion';
 import InterceptorManager from '~/services/interceptor-manager';
-import Xss from '~/services/xss';
 import loggerFactory from '~/utils/logger';
 
 import EmptyTrashModal from '../components/EmptyTrashModal';
@@ -31,10 +30,6 @@ if (!window) {
   window = {};
 }
 
-// setup xss library
-const xss = new Xss();
-window.xss = xss;
-
 window.globalEmitter = new EventEmitter();
 window.interceptorManager = new InterceptorManager();
 

+ 3 - 0
packages/app/src/client/interfaces/clearable.ts

@@ -0,0 +1,3 @@
+export interface IClearable {
+  clear: () => void,
+}

+ 23 - 0
packages/app/src/client/services/page-operation.ts

@@ -37,6 +37,29 @@ export const toggleBookmark = async(pageId: string, currentValue?: boolean): Pro
   }
 };
 
+// Utility to update body class
+const updateBodyClassByView = (expandContentWidth: boolean): void => {
+  const bodyClasses = document.body.classList;
+  const isLayoutFluid = bodyClasses.contains('growi-layout-fluid');
+
+  if (expandContentWidth && !isLayoutFluid) {
+    bodyClasses.add('growi-layout-fluid');
+  }
+  else if (isLayoutFluid) {
+    bodyClasses.remove('growi-layout-fluid');
+  }
+};
+
+export const updateContentWidth = async(pageId: string, newValue: boolean): Promise<void> => {
+  try {
+    await apiv3Put(`/page/${pageId}/content-width`, { expandContentWidth: newValue });
+    updateBodyClassByView(newValue);
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
 export const bookmark = async(pageId: string): Promise<void> => {
   try {
     await apiv3Put('/bookmarks', { pageId, bool: true });

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

@@ -1,7 +1,11 @@
-import React, { FC } from 'react';
+import React, { FC, useState, useCallback } from 'react';
 
+import { pagePathUtils } from '@growi/core';
+import { UserPicture } from '@growi/ui';
 import { format } from 'date-fns';
+import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { useTranslation } from 'react-i18next';
+import { Tooltip } from 'reactstrap';
 
 import { IActivityHasId } from '~/interfaces/activity';
 
@@ -15,13 +19,21 @@ const formatDate = (date) => {
 
 export const ActivityTable : FC<Props> = (props: Props) => {
   const { t } = useTranslation();
+  const [tooltopOpen, setTooltipOpen] = useState(false);
+
+  const showToolTip = useCallback(() => {
+    setTooltipOpen(true);
+    setTimeout(() => {
+      setTooltipOpen(false);
+    }, 1000);
+  }, [setTooltipOpen]);
 
   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.user')}</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>
@@ -32,11 +44,28 @@ export const ActivityTable : FC<Props> = (props: Props) => {
           {props.activityList.map((activity) => {
             return (
               <tr data-testid="activity-table" key={activity._id}>
-                <td>{activity.snapshot?.username}</td>
+                <td>
+                  { activity.user != null && (
+                    <>
+                      <UserPicture user={activity.user} className="picture rounded-circle" />
+                      <a className="ml-2" href={pagePathUtils.userPageRoot(activity.user)}>{activity.snapshot?.username}</a>
+                    </>
+                  )}
+                </td>
                 <td>{formatDate(activity.createdAt)}</td>
-                <td>{activity.action}</td>
+                <td>{t(`admin:audit_log_action.${activity.action}`)}</td>
                 <td>{activity.ip}</td>
-                <td>{activity.endpoint}</td>
+                <td>
+                  {activity.endpoint}
+                  <CopyToClipboard text={activity.endpoint} onCopy={showToolTip}>
+                    <button type="button" className="btn btn-outline-secondary border-0 pull-right" id="tooltipTarget">
+                      <i className="fa fa-clipboard" aria-hidden="true"></i>
+                    </button>
+                  </CopyToClipboard>
+                  <Tooltip placement="top" isOpen={tooltopOpen} fade={false} target="tooltipTarget">
+                    copied!
+                  </Tooltip>
+                </td>
               </tr>
             );
           })}

+ 19 - 3
packages/app/src/components/Admin/AuditLog/AuditLogSettings.tsx

@@ -3,6 +3,7 @@ import React, { FC, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Collapse } from 'reactstrap';
 
+import { AllSupportedActions } from '~/interfaces/activity';
 import { useActivityExpirationSeconds, useAuditLogAvailableActions } from '~/stores/context';
 
 export const AuditLogSettings: FC = () => {
@@ -34,8 +35,23 @@ export const AuditLogSettings: FC = () => {
         />
       </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>
+      <h4 className="mt-4">
+        {t('admin:audit_log_management.available_action_list')}
+        <span className="badge badge-pill badge-secondary ml-2">
+          {`${availableActions.length} / ${AllSupportedActions.length}`}
+        </span>
+        <a
+          className="ml-2"
+          href={t('admin:audit_log_management.docs_url.log_type')}
+          target="_blank"
+          rel="noopener noreferrer"
+        >
+          <i className="icon-fw icon-question" aria-hidden="true"></i>
+        </a>
+      </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>
@@ -45,7 +61,7 @@ export const AuditLogSettings: FC = () => {
       <Collapse isOpen={isExpandActionList}>
         <ul className="list-group">
           { availableActions.map(action => (
-            <li key={action} className="list-group-item">{ action }</li>
+            <li key={action} className="list-group-item">{t(`admin:audit_log_action.${action}`)}</li>
           )) }
         </ul>
       </Collapse>

+ 28 - 21
packages/app/src/components/Admin/AuditLog/DateRangePicker.tsx

@@ -1,33 +1,42 @@
-import React, {
-  FC, useRef, forwardRef, useCallback,
-} from 'react';
+import React, { FC, forwardRef, useCallback } from 'react';
 
+import { addDays, format } from 'date-fns';
 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;
+  value?: string
+  onChange?: () => void
+  onFocus?: () => void
 }
 
-const CustomInput = forwardRef<HTMLButtonElement, CustomInputProps>((props: CustomInputProps) => {
-  const { t } = useTranslation();
+const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>((props: CustomInputProps, ref) => {
+  const dateFormat = 'MM/dd/yyyy';
+  const date = new Date();
+  const placeholder = `${format(date, dateFormat)} - ${format(addDays(date, 1), dateFormat)}`;
+
   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>
+    <div className="input-group admin-audit-log">
+      <div className="input-group-prepend">
+        <span className="input-group-text">
+          <i className="fa fa-fw fa-calendar" />
+        </span>
+      </div>
+      <input
+        ref={ref}
+        type="text"
+        value={props?.value}
+        onFocus={props?.onFocus}
+        onChange={props?.onChange}
+        placeholder={placeholder}
+        className="form-control date-range-picker"
+        aria-describedby="basic-addon1"
+      />
+    </div>
   );
 });
 
-
 type DateRangePickerProps = {
   startDate: Date | null
   endDate: Date | null
@@ -37,8 +46,6 @@ type DateRangePickerProps = {
 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;
@@ -59,7 +66,7 @@ export const DateRangePicker: FC<DateRangePickerProps> = (props: DateRangePicker
         startDate={startDate}
         endDate={endDate}
         onChange={changeHandler}
-        customInput={<CustomInput buttonRef={buttonRef} />}
+        customInput={<CustomInput />}
       />
     </div>
   );

+ 18 - 3
packages/app/src/components/Admin/AuditLog/SearchUsernameTypeahead.tsx

@@ -1,10 +1,11 @@
 import React, {
-  FC, Fragment, useState, useCallback,
+  Fragment, useState, useCallback, useRef, ForwardRefRenderFunction, forwardRef, useImperativeHandle,
 } from 'react';
 
 import { AsyncTypeahead, Menu, MenuItem } from 'react-bootstrap-typeahead';
 import { useTranslation } from 'react-i18next';
 
+import { IClearable } from '~/client/interfaces/clearable';
 import { useSWRxUsernames } from '~/stores/user';
 
 
@@ -25,10 +26,12 @@ type Props = {
   onChange: (text: string[]) => void
 }
 
-export const SearchUsernameTypeahead: FC<Props> = (props: Props) => {
+const SearchUsernameTypeaheadSubstance: ForwardRefRenderFunction<IClearable, Props> = ((props: Props, ref) => {
   const { onChange } = props;
   const { t } = useTranslation();
 
+  const typeaheadRef = useRef<IClearable>(null);
+
   /*
    * State
    */
@@ -96,6 +99,15 @@ export const SearchUsernameTypeahead: FC<Props> = (props: Props) => {
     );
   }, []);
 
+  useImperativeHandle(ref, () => ({
+    clear() {
+      const instance = typeaheadRef?.current;
+      if (instance != null) {
+        instance.clear();
+      }
+    },
+  }));
+
   return (
     <div className="input-group mr-2">
       <div className="input-group-prepend">
@@ -104,6 +116,7 @@ export const SearchUsernameTypeahead: FC<Props> = (props: Props) => {
         </span>
       </div>
       <AsyncTypeahead
+        ref={typeaheadRef}
         id="search-username-typeahead-asynctypeahead"
         multiple
         delay={400}
@@ -119,4 +132,6 @@ export const SearchUsernameTypeahead: FC<Props> = (props: Props) => {
       />
     </div>
   );
-};
+});
+
+export const SearchUsernameTypeahead = forwardRef(SearchUsernameTypeaheadSubstance);

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

@@ -91,7 +91,7 @@ export const SelectActionDropdown: FC<Props> = (props: Props) => {
                   defaultChecked
                   onChange={(e) => { multipleActionCheckboxChangedHandler(item.actions, e.target.checked) }}
                 />
-                <label className="form-check-label">{item.actionCategory}</label>
+                <label className="form-check-label">{t(`admin:audit_log_action_category.${item.actionCategory}`)}</label>
               </div>
             </div>
             {
@@ -109,7 +109,7 @@ export const SelectActionDropdown: FC<Props> = (props: Props) => {
                       className="form-check-label"
                       htmlFor={`checkbox${action}`}
                     >
-                      {action}
+                      {t(`admin:audit_log_action.${action}`)}
                     </label>
                   </div>
                 </div>

+ 87 - 24
packages/app/src/components/Admin/AuditLogManagement.tsx

@@ -1,8 +1,11 @@
-import React, { FC, useState, useCallback } from 'react';
+import React, {
+  FC, useState, useCallback, useRef,
+} from 'react';
 
 import { format } from 'date-fns';
 import { useTranslation } from 'react-i18next';
 
+import { IClearable } from '~/client/interfaces/clearable';
 import { toastError } from '~/client/util/apiNotification';
 import { SupportedActionType } from '~/interfaces/activity';
 import { useSWRxActivity } from '~/stores/activity';
@@ -17,7 +20,6 @@ import { DateRangePicker } from './AuditLog/DateRangePicker';
 import { SearchUsernameTypeahead } from './AuditLog/SearchUsernameTypeahead';
 import { SelectActionDropdown } from './AuditLog/SelectActionDropdown';
 
-
 const formatDate = (date: Date | null) => {
   if (date == null) {
     return '';
@@ -30,20 +32,22 @@ const PAGING_LIMIT = 10;
 export const AuditLogManagement: FC = () => {
   const { t } = useTranslation();
 
+  const typeaheadRef = useRef<IClearable>(null);
+
   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 [activePageNumber, setActivePageNumber] = useState<number>(1);
+  const [jumpPageNumber, setJumpPageNumber] = useState<number>(1);
+  const offset = (activePageNumber - 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])),
+    new Map<SupportedActionType, boolean>(auditLogAvailableActionsData != null ? auditLogAvailableActionsData.map(action => [action, true]) : []),
   );
 
   /*
@@ -56,6 +60,7 @@ export const AuditLogManagement: FC = () => {
   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 totalPagingPages = activityData?.totalPages != null ? activityData.totalPages : 0;
   const isLoading = activityData === undefined && error == null;
 
   if (error != null) {
@@ -68,39 +73,75 @@ export const AuditLogManagement: FC = () => {
    * Functions
    */
   const setActivePageHandler = useCallback((selectedPageNum: number) => {
-    setActivePage(selectedPageNum);
+    setActivePageNumber(selectedPageNum);
   }, []);
 
   const datePickerChangedHandler = useCallback((dateList: Date[] | null[]) => {
-    setActivePage(1);
+    setActivePageNumber(1);
     setStartDate(dateList[0]);
     setEndDate(dateList[1]);
   }, []);
 
   const actionCheckboxChangedHandler = useCallback((action: SupportedActionType) => {
-    setActivePage(1);
+    setActivePageNumber(1);
     actionMap.set(action, !actionMap.get(action));
     setActionMap(new Map(actionMap.entries()));
   }, [actionMap, setActionMap]);
 
   const multipleActionCheckboxChangedHandler = useCallback((actions: SupportedActionType[], isChecked) => {
-    setActivePage(1);
+    setActivePageNumber(1);
     actions.forEach(action => actionMap.set(action, isChecked));
     setActionMap(new Map(actionMap.entries()));
   }, [actionMap, setActionMap]);
 
   const setUsernamesHandler = useCallback((usernames: string[]) => {
-    setActivePage(1);
+    setActivePageNumber(1);
     setSelectedUsernames(usernames);
   }, []);
 
+  const clearButtonPushedHandler = useCallback(() => {
+    setActivePageNumber(1);
+    setStartDate(null);
+    setEndDate(null);
+    setSelectedUsernames([]);
+    typeaheadRef.current?.clear();
+
+    if (auditLogAvailableActionsData != null) {
+      setActionMap(new Map<SupportedActionType, boolean>(auditLogAvailableActionsData.map(action => [action, true])));
+    }
+  }, [setActivePageNumber, setStartDate, setEndDate, setSelectedUsernames, setActionMap, auditLogAvailableActionsData]);
+
   const reloadButtonPushedHandler = useCallback(() => {
-    setActivePage(1);
+    setActivePageNumber(1);
     mutateActivity();
   }, [mutateActivity]);
 
+  const jumpPageInputChangeHandler = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
+    const inputNumber = Number(e.target.value);
+    const isNan = Number.isNaN(inputNumber);
+
+    if (!isNan) {
+      // eslint-disable-next-line no-nested-ternary
+      const jumpPageNumber = inputNumber > totalPagingPages ? totalPagingPages : inputNumber <= 0 ? activePageNumber : inputNumber;
+      setJumpPageNumber(jumpPageNumber);
+    }
+    else {
+      setJumpPageNumber(activePageNumber);
+    }
+  }, [totalPagingPages, activePageNumber, setJumpPageNumber]);
+
+  const jumpPageInputKeyDownHandler = useCallback((e) => {
+    if (e.key === 'Enter') {
+      setActivePageNumber(jumpPageNumber);
+    }
+  }, [setActivePageNumber, jumpPageNumber]);
+
+  const jumpPageButtonPushedHandler = useCallback(() => {
+    setActivePageNumber(jumpPageNumber);
+  }, [jumpPageNumber]);
+
   // 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/>`;
+  const activityCounter = `<b>${activityList.length === 0 ? 0 : offset + 1}</b> - <b>${(PAGING_LIMIT * activePageNumber) - (PAGING_LIMIT - activityList.length)}</b> of <b>${totalActivityNum}<b/>`;
 
   if (!auditLogEnabled) {
     return <AuditLogDisableMode />;
@@ -120,6 +161,11 @@ export const AuditLogManagement: FC = () => {
         <span>
           {isSettingPage ? t('AuditLog Settings') : t('AuditLog')}
         </span>
+        { !isSettingPage && (
+          <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={reloadButtonPushedHandler}>
+            <i className="icon icon-reload"></i>
+          </button>
+        )}
       </h2>
 
       {isSettingPage ? (
@@ -128,6 +174,7 @@ export const AuditLogManagement: FC = () => {
         <>
           <div className="form-inline mb-3">
             <SearchUsernameTypeahead
+              ref={typeaheadRef}
               onChange={setUsernamesHandler}
             />
 
@@ -139,13 +186,13 @@ export const AuditLogManagement: FC = () => {
 
             <SelectActionDropdown
               actionMap={actionMap}
-              availableActions={auditLogAvailableActions}
+              availableActions={auditLogAvailableActionsData || []}
               onChangeAction={actionCheckboxChangedHandler}
               onChangeMultipleAction={multipleActionCheckboxChangedHandler}
             />
 
-            <button type="button" className="btn ml-auto grw-btn-reload" onClick={reloadButtonPushedHandler}>
-              <i className="icon icon-reload" />
+            <button type="button" className="btn btn-link" onClick={clearButtonPushedHandler}>
+              {t('admin:audit_log_management.clear')}
             </button>
           </div>
 
@@ -166,14 +213,30 @@ export const AuditLogManagement: FC = () => {
             )
           }
 
-          <PaginationWrapper
-            activePage={activePage}
-            changePage={setActivePageHandler}
-            totalItemsCount={totalActivityNum}
-            pagingLimit={PAGING_LIMIT}
-            align="center"
-            size="sm"
-          />
+          <div className="d-flex flex-row justify-content-center">
+            <PaginationWrapper
+              activePage={activePageNumber}
+              changePage={setActivePageHandler}
+              totalItemsCount={totalActivityNum}
+              pagingLimit={PAGING_LIMIT}
+              align="center"
+              size="sm"
+            />
+
+            <div className="admin-audit-log ml-3">
+              <label htmlFor="jumpPageInput" className="mr-1 text-secondary">Jump To Page</label>
+              <input
+                id="jumpPageInput"
+                type="text"
+                className="jump-page-input"
+                onChange={jumpPageInputChangeHandler}
+                onKeyDown={jumpPageInputKeyDownHandler}
+              />
+              <button className="btn btn-sm" type="button" onClick={jumpPageButtonPushedHandler}>
+                <b>Go</b>
+              </button>
+            </div>
+          </div>
         </>
       )}
     </div>

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

@@ -17,6 +17,7 @@ import CustomizeFunctionSetting from './CustomizeFunctionSetting';
 import CustomizeHeaderSetting from './CustomizeHeaderSetting';
 import CustomizeHighlightSetting from './CustomizeHighlightSetting';
 import CustomizeLayoutSetting from './CustomizeLayoutSetting';
+import CustomizeLogoSetting from './CustomizeLogoSetting';
 import CustomizeScriptSetting from './CustomizeScriptSetting';
 import CustomizeSidebarSetting from './CustomizeSidebarSetting';
 import CustomizeThemeSetting from './CustomizeThemeSetting';
@@ -76,6 +77,9 @@ function Customize(props) {
       <div className="mb-5">
         <CustomizeScriptSetting />
       </div>
+      <div className="mb-5">
+        <CustomizeLogoSetting />
+      </div>
     </div>
   );
 }

+ 185 - 0
packages/app/src/components/Admin/Customize/CustomizeLogoSetting.tsx

@@ -0,0 +1,185 @@
+import React, { useCallback, useEffect, useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { toastError, toastSuccess } from '~/client/util/apiNotification';
+import {
+  apiv3Delete, apiv3Get, apiv3PostForm, apiv3Put,
+} from '~/client/util/apiv3-client';
+import ImageCropModal from '~/components/Common/ImageCropModal';
+
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+const DEFAULT_LOGO = '/images/logo.svg';
+
+const CustomizeLogoSetting = (): JSX.Element => {
+
+  const { t } = useTranslation();
+
+  const [uploadLogoSrc, setUploadLogoSrc] = useState<ArrayBuffer | string | null>(null);
+  const [isImageCropModalShow, setIsImageCropModalShow] = useState<boolean>(false);
+  const [isDefaultLogo, setIsDefaultLogo] = useState<boolean>(true);
+  const [retrieveError, setRetrieveError] = useState<string | null>(null);
+  const [customizedLogoSrc, setCustomizedLogoSrc] = useState< string | null >(null);
+
+  const retrieveData = useCallback(async() => {
+    try {
+      const response = await apiv3Get('/customize-setting/customize-logo');
+      const { isDefaultLogo: _isDefaultLogo, customizedLogoSrc } = response.data;
+      const isDefaultLogo = _isDefaultLogo ?? true;
+
+      setIsDefaultLogo(isDefaultLogo);
+      setCustomizedLogoSrc(customizedLogoSrc);
+    }
+    catch (err) {
+      setRetrieveError(err);
+      throw new Error('Failed to fetch data');
+    }
+  }, []);
+
+  useEffect(() => {
+    retrieveData();
+  }, [retrieveData]);
+
+  const onSelectFile = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
+    if (e.target.files != null && e.target.files.length > 0) {
+      const reader = new FileReader();
+      reader.addEventListener('load', () => setUploadLogoSrc(reader.result));
+      reader.readAsDataURL(e.target.files[0]);
+      setIsImageCropModalShow(true);
+    }
+  }, []);
+
+  const onClickSubmit = useCallback(async() => {
+    try {
+      const response = await apiv3Put('/customize-setting/customize-logo', {
+        isDefaultLogo,
+        customizedLogoSrc,
+      });
+      const { customizedParams } = response.data;
+      setIsDefaultLogo(customizedParams.isDefaultLogo);
+      setCustomizedLogoSrc(customizedParams.customizedLogoSrc);
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.custom_logo') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, isDefaultLogo, customizedLogoSrc]);
+
+
+  const onClickDeleteBtn = useCallback(async() => {
+    try {
+      await apiv3Delete('/customize-setting/delete-brand-logo');
+      setCustomizedLogoSrc(null);
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.current_logo') }));
+    }
+    catch (err) {
+      toastError(err);
+      setRetrieveError(err);
+      throw new Error('Failed to delete logo');
+    }
+  }, [t]);
+
+  const onCropCompleted = useCallback(async(croppedImage) => {
+    try {
+      const formData = new FormData();
+      formData.append('file', croppedImage);
+      const { data } = await apiv3PostForm('/customize-setting/upload-brand-logo', formData);
+      setCustomizedLogoSrc(data.attachment.filePathProxied);
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.current_logo') }));
+    }
+    catch (err) {
+      toastError(err);
+      setRetrieveError(err);
+      throw new Error('Failed to upload brand logo');
+    }
+    setIsImageCropModalShow(false);
+  }, [t]);
+
+
+  return (
+    <React.Fragment>
+      <div className="row">
+        <div className="col-12">
+          <div className="mb-5">
+            <h2 className="border-bottom my-4 admin-setting-header">{t('admin:customize_setting.custom_logo')}</h2>
+            <div className="row">
+              <div className="col-md-6 col-12 mb-3 mb-md-0">
+                <h4>
+                  <div className="custom-control custom-radio radio-primary">
+                    <input
+                      type="radio"
+                      id="radioDefaultLogo"
+                      className="custom-control-input"
+                      form="formImageType"
+                      name="imagetypeForm[isDefaultLogo]"
+                      checked={isDefaultLogo}
+                      onChange={() => { setIsDefaultLogo(true) }}
+                    />
+                    <label className="custom-control-label" htmlFor="radioDefaultLogo">
+                      {t('admin:customize_setting.default_logo')}
+                    </label>
+                  </div>
+                </h4>
+                <img src={DEFAULT_LOGO} width="64" />
+              </div>
+              <div className="col-md-6 col-12">
+                <h4>
+                  <div className="custom-control custom-radio radio-primary">
+                    <input
+                      type="radio"
+                      id="radioUploadLogo"
+                      className="custom-control-input"
+                      form="formImageType"
+                      name="imagetypeForm[isDefaultLogo]"
+                      checked={!isDefaultLogo}
+                      onChange={() => { setIsDefaultLogo(false) }}
+                    />
+                    <label className="custom-control-label" htmlFor="radioUploadLogo">
+                      { t('admin:customize_setting.upload_logo') }
+                    </label>
+                  </div>
+                </h4>
+                <div className="row mb-3">
+                  <label className="col-sm-4 col-12 col-form-label text-left">
+                    { t('admin:customize_setting.current_logo') }
+                  </label>
+                  <div className="col-sm-8 col-12">
+                    <p><img src={customizedLogoSrc || DEFAULT_LOGO} className="picture picture-lg " id="settingBrandLogo" width="64" /></p>
+                    {(customizedLogoSrc != null) && (
+                      <button type="button" className="btn btn-danger" onClick={onClickDeleteBtn}>
+                        { t('admin:customize_setting.delete_logo') }
+                      </button>
+                    )}
+                  </div>
+                </div>
+                <div className="row">
+                  <label className="col-sm-4 col-12 col-form-label text-left">
+                    { t('admin:customize_setting.upload_new_logo') }
+                  </label>
+                  <div className="col-sm-8 col-12">
+                    <input type="file" onChange={onSelectFile} name="brandLogo" accept="image/*" />
+                  </div>
+                </div>
+              </div>
+            </div>
+            <AdminUpdateButtonRow onClick={onClickSubmit} disabled={retrieveError != null} />
+          </div>
+        </div>
+      </div>
+
+      <ImageCropModal
+        isShow={isImageCropModalShow}
+        src={uploadLogoSrc}
+        onModalClose={() => setIsImageCropModalShow(false)}
+        onCropCompleted={onCropCompleted}
+        isCircular={false}
+      />
+    </React.Fragment>
+  );
+
+
+};
+
+
+export default CustomizeLogoSetting;

+ 4 - 2
packages/app/src/components/Admin/ExportArchiveData/SelectCollectionsModal.jsx

@@ -13,13 +13,15 @@ import { apiPost } from '~/client/util/apiv1-client';
 
 
 const GROUPS_PAGE = [
-  'pages', 'revisions', 'tags', 'pagetagrelations',
+  'pages', 'revisions', 'tags', 'pagetagrelations', 'pageredirects', 'comments', 'sharelinks',
 ];
 const GROUPS_USER = [
   'users', 'externalaccounts', 'usergroups', 'usergrouprelations',
+  'useruisettings', 'editorsettings', 'bookmarks', 'subscriptions',
+  'inappnotificationsettings',
 ];
 const GROUPS_CONFIG = [
-  'configs', 'updateposts', 'globalnotificationsettings',
+  'configs', 'updateposts', 'globalnotificationsettings', 'slackappintegrations',
 ];
 const ALL_GROUPED_COLLECTIONS = GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
 

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

@@ -19,7 +19,7 @@ import SelectCollectionsModal from './ExportArchiveData/SelectCollectionsModal';
 
 
 const IGNORED_COLLECTION_NAMES = [
-  'sessions',
+  'sessions', 'rlflx', 'activities',
 ];
 
 class ExportArchiveDataPage extends React.Component {

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

@@ -17,7 +17,6 @@ class ManageExternalAccount extends React.Component {
 
   constructor(props) {
     super(props);
-    this.xss = window.xss;
     this.handleExternalAccountPage = this.handleExternalAccountPage.bind(this);
   }
 

+ 21 - 23
packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx

@@ -1,6 +1,7 @@
 import React, {
   FC, useCallback, useState, useMemo,
 } from 'react';
+
 import { TFunctionResult } from 'i18next';
 import { useTranslation } from 'react-i18next';
 import {
@@ -8,8 +9,6 @@ import {
 } from 'reactstrap';
 
 import { IUserGroupHasId } from '~/interfaces/user';
-import { CustomWindow } from '~/interfaces/global';
-import Xss from '~/services/xss';
 
 /**
  * Delete User Group Select component
@@ -42,10 +41,13 @@ const actionForPages = {
 };
 
 const UserGroupDeleteModal: FC<Props> = (props: Props) => {
-  const xss: Xss = (window as CustomWindow).xss;
 
   const { t } = useTranslation();
 
+  const {
+    onHide, onDelete, userGroups, deleteUserGroup,
+  } = props;
+
   const availableOptions = useMemo<AvailableOption[]>(() => {
     return [
       {
@@ -70,7 +72,7 @@ const UserGroupDeleteModal: FC<Props> = (props: Props) => {
         label: t('admin:user_group_management.delete_modal.transfer_pages'),
       },
     ];
-  }, []);
+  }, [t]);
 
   /*
    * State
@@ -86,14 +88,14 @@ const UserGroupDeleteModal: FC<Props> = (props: Props) => {
     setTransferToUserGroupId('');
   }, []);
 
-  const onHide = useCallback(() => {
-    if (props.onHide == null) {
+  const toggleHandler = useCallback(() => {
+    if (onHide == null) {
       return;
     }
 
     resetStates();
-    props.onHide();
-  }, [props.onHide]);
+    onHide();
+  }, [onHide, resetStates]);
 
   const handleActionChange = useCallback((e) => {
     const actionName = e.target.value;
@@ -106,23 +108,22 @@ const UserGroupDeleteModal: FC<Props> = (props: Props) => {
   }, []);
 
   const handleSubmit = useCallback((e) => {
-    if (props.onDelete == null || props.deleteUserGroup == null) {
+    if (onDelete == null || deleteUserGroup == null) {
       return;
     }
 
     e.preventDefault();
 
-    props.onDelete(
-      props.deleteUserGroup._id,
+    onDelete(
+      deleteUserGroup._id,
       actionName,
       transferToUserGroupId,
     );
-  }, [props.onDelete, props.deleteUserGroup, actionName, transferToUserGroupId]);
+  }, [onDelete, deleteUserGroup, actionName, transferToUserGroupId]);
 
   const renderPageActionSelector = useCallback(() => {
     const options = availableOptions.map((opt) => {
-      const dataContent = `<i class="icon icon-fw ${opt.iconClass} ${opt.styleClass}"></i> <span class="action-name ${opt.styleClass}">${opt.label}</span>`;
-      return <option key={opt.id} value={opt.actionForPages} data-content={dataContent}>{opt.label}</option>;
+      return <option key={opt.id} value={opt.actionForPages}>{opt.label}</option>;
     });
 
     return (
@@ -137,22 +138,19 @@ const UserGroupDeleteModal: FC<Props> = (props: Props) => {
         {options}
       </select>
     );
-  }, [handleActionChange, actionName, availableOptions]);
+  }, [availableOptions, actionName, handleActionChange, t]);
 
   const renderGroupSelector = useCallback(() => {
-    const { deleteUserGroup } = props;
-
     if (deleteUserGroup == null) {
       return;
     }
 
-    const groups = props.userGroups.filter((group) => {
+    const groups = userGroups.filter((group) => {
       return group._id !== deleteUserGroup._id;
     });
 
     const options = groups.map((group) => {
-      const dataContent = `<i class="icon icon-fw icon-organization"></i> ${xss.process(group.name)}`;
-      return <option key={group._id} value={group._id} data-content={dataContent}>{xss.process(group.name)}</option>;
+      return <option key={group._id} value={group._id}>{group.name}</option>;
     });
 
     const defaultOptionText = groups.length === 0 ? t('admin:user_group_management.delete_modal.no_groups')
@@ -169,7 +167,7 @@ const UserGroupDeleteModal: FC<Props> = (props: Props) => {
         {options}
       </select>
     );
-  }, [actionName, transferToUserGroupId, props.userGroups, props.deleteUserGroup]);
+  }, [deleteUserGroup, userGroups, t, actionName, transferToUserGroupId, handleGroupChange]);
 
   const validateForm = useCallback(() => {
     let isValid = true;
@@ -185,8 +183,8 @@ const UserGroupDeleteModal: FC<Props> = (props: Props) => {
   }, [actionName, transferToUserGroupId]);
 
   return (
-    <Modal className="modal-md" isOpen={props.isShow} toggle={onHide}>
-      <ModalHeader tag="h4" toggle={onHide} className="bg-danger text-light">
+    <Modal className="modal-md" isOpen={props.isShow} toggle={toggleHandler}>
+      <ModalHeader tag="h4" toggle={toggleHandler} className="bg-danger text-light">
         <i className="icon icon-fire"></i> {t('admin:user_group_management.delete_modal.header')}
       </ModalHeader>
       <ModalBody>

+ 2 - 4
packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -1,11 +1,10 @@
 import React, { FC, useCallback, useState } from 'react';
-import { useTranslation } from 'react-i18next';
+
 import dateFnsFormat from 'date-fns/format';
 import { TFunctionResult } from 'i18next';
+import { useTranslation } from 'react-i18next';
 
 import { IUserGroupHasId } from '~/interfaces/user';
-import { CustomWindow } from '~/interfaces/global';
-import Xss from '~/services/xss';
 
 type Props = {
   userGroup: IUserGroupHasId,
@@ -15,7 +14,6 @@ type Props = {
 };
 
 const UserGroupForm: FC<Props> = (props: Props) => {
-  const xss: Xss = (window as CustomWindow).xss;
 
   const { t } = useTranslation();
 

+ 3 - 5
packages/app/src/components/Admin/UserGroup/UserGroupModal.tsx

@@ -1,16 +1,15 @@
 import React, {
   FC, useState, useEffect, useCallback,
 } from 'react';
+
+import { TFunctionResult } from 'i18next';
+import { useTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-import { TFunctionResult } from 'i18next';
 
 import { Ref } from '~/interfaces/common';
 import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
-import { CustomWindow } from '~/interfaces/global';
-import Xss from '~/services/xss';
 
 type Props = {
   userGroup?: IUserGroupHasId,
@@ -21,7 +20,6 @@ type Props = {
 };
 
 const UserGroupModal: FC<Props> = (props: Props) => {
-  const xss: Xss = (window as CustomWindow).xss;
 
   const { t } = useTranslation();
 

+ 7 - 8
packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx

@@ -1,20 +1,19 @@
 import React, { FC, useState, useCallback } from 'react';
+
 import { useTranslation } from 'react-i18next';
 
-import UserGroupTable from './UserGroupTable';
-import UserGroupModal from './UserGroupModal';
-import UserGroupDeleteModal from './UserGroupDeleteModal';
 
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
-import Xss from '~/services/xss';
-import { CustomWindow } from '~/interfaces/global';
 import { apiv3Delete, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
-import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
+import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
 import { useIsAclEnabled } from '~/stores/context';
+import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
+
+import UserGroupDeleteModal from './UserGroupDeleteModal';
+import UserGroupModal from './UserGroupModal';
+import UserGroupTable from './UserGroupTable';
 
 const UserGroupPage: FC = () => {
-  const xss: Xss = (window as CustomWindow).xss;
   const { t } = useTranslation();
 
   const { data: isAclEnabled } = useIsAclEnabled();

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

@@ -1,14 +1,12 @@
 import React, {
-  FC, useState, useCallback, useEffect,
+  FC, useState, useEffect,
 } from 'react';
 
-import { useTranslation } from 'react-i18next';
-import { TFunctionResult } from 'i18next';
 import dateFnsFormat from 'date-fns/format';
+import { TFunctionResult } from 'i18next';
+import { useTranslation } from 'react-i18next';
 
-import Xss from '~/services/xss';
 import { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '~/interfaces/user';
-import { CustomWindow } from '~/interfaces/global';
 
 type Props = {
   headerLabel?: TFunctionResult,
@@ -56,7 +54,6 @@ const generateGroupIdToChildGroupsMap = (childUserGroups: IUserGroupHasId[]): Re
 
 
 const UserGroupTable: FC<Props> = (props: Props) => {
-  const xss: Xss = (window as CustomWindow).xss;
   const { t } = useTranslation();
 
   /*
@@ -151,17 +148,17 @@ const UserGroupTable: FC<Props> = (props: Props) => {
               <tr key={group._id}>
                 {props.isAclEnabled
                   ? (
-                    <td><a href={`/admin/user-group-detail/${group._id}`}>{xss.process(group.name)}</a></td>
+                    <td><a href={`/admin/user-group-detail/${group._id}`}>{group.name}</a></td>
                   )
                   : (
-                    <td>{xss.process(group.name)}</td>
+                    <td>{group.name}</td>
                   )
                 }
-                <td>{xss.process(group.description)}</td>
+                <td>{group.description}</td>
                 <td>
                   <ul className="list-inline">
                     {users != null && users.map((user) => {
-                      return <li key={user._id} className="list-inline-item badge badge-pill badge-warning">{xss.process(user.username)}</li>;
+                      return <li key={user._id} className="list-inline-item badge badge-pill badge-warning">{user.username}</li>;
                     })}
                   </ul>
                 </td>
@@ -172,10 +169,10 @@ const UserGroupTable: FC<Props> = (props: Props) => {
                         <li key={group._id} className="list-inline-item badge badge-success">
                           {props.isAclEnabled
                             ? (
-                              <a href={`/admin/user-group-detail/${group._id}`}>{xss.process(group.name)}</a>
+                              <a href={`/admin/user-group-detail/${group._id}`}>{group.name}</a>
                             )
                             : (
-                              <p>{xss.process(group.name)}</p>
+                              <p>{group.name}</p>
                             )
                           }
                         </li>

+ 2 - 1
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx

@@ -9,6 +9,7 @@ import { debounce } from 'throttle-debounce';
 import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
 import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import Xss from '~/services/xss';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -25,7 +26,7 @@ class UserGroupUserFormByInput extends React.Component {
       searchError: null,
     };
 
-    this.xss = window.xss;
+    this.xss = new Xss();
 
     this.addUserBySubmit = this.addUserBySubmit.bind(this);
     this.validateForm = this.validateForm.bind(this);

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

@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next';
 import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
 import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import Xss from '~/services/xss';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -16,7 +17,7 @@ class UserGroupUserTable extends React.Component {
   constructor(props) {
     super(props);
 
-    this.xss = window.xss;
+    this.xss = new Xss();
 
     this.removeUser = this.removeUser.bind(this);
   }

+ 20 - 10
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -11,6 +11,7 @@ import {
 import { IPageOperationProcessData } from '~/interfaces/page-operation';
 import { useSWRxPageInfo } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
+import { shouldRecoverPagePaths } from '~/utils/page-operation';
 
 const logger = loggerFactory('growi:cli:PageItemControl');
 
@@ -22,6 +23,7 @@ export const MenuItemType = {
   DELETE: 'delete',
   REVERT: 'revert',
   PATH_RECOVERY: 'pathRecovery',
+  SWITCH_CONTENT_WIDTH: 'switch_content_width',
 } as const;
 export type MenuItemType = typeof MenuItemType[keyof typeof MenuItemType];
 
@@ -41,6 +43,7 @@ type CommonProps = {
   onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
   onClickPathRecoveryMenuItem?: (pageId: string) => Promise<void> | void,
 
+  additionalMenuItemOnTopRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   isInstantRename?: boolean,
   alignRight?: boolean,
@@ -57,13 +60,14 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
   const { t } = useTranslation('');
 
   const {
-    pageId, isLoading,
-    pageInfo, isEnableActions, forceHideMenuItems, operationProcessData,
-    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickRevertMenuItem, onClickPathRecoveryMenuItem,
-    additionalMenuItemRenderer: AdditionalMenuItems, isInstantRename, alignRight,
+    pageId, isLoading, pageInfo, isEnableActions, forceHideMenuItems, operationProcessData,
+    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem,
+    onClickRevertMenuItem, onClickPathRecoveryMenuItem,
+    additionalMenuItemOnTopRenderer: AdditionalMenuItemsOnTop,
+    additionalMenuItemRenderer: AdditionalMenuItems,
+    isInstantRename, alignRight,
   } = props;
 
-
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const bookmarkItemClickedHandler = useCallback(async() => {
     if (!isIPageInfoForOperation(pageInfo) || onClickBookmarkMenuItem == null) {
@@ -136,7 +140,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
     // PathRecovery
     // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
-    const shouldShowPathRecoveryButton = operationProcessData?.Rename != null ? operationProcessData?.Rename.isProcessable : false;
+    const shouldShowPathRecoveryButton = operationProcessData != null ? shouldRecoverPagePaths(operationProcessData) : false;
 
     contents = (
       <>
@@ -148,8 +152,15 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           </DropdownItem>
         ) }
 
+        { AdditionalMenuItemsOnTop && (
+          <>
+            <AdditionalMenuItemsOnTop pageInfo={pageInfo} />
+            <DropdownItem divider />
+          </>
+        ) }
+
         {/* Bookmark */}
-        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && !pageInfo.isEmpty && isIPageInfoForOperation(pageInfo) && (
+        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && isIPageInfoForOperation(pageInfo) && (
           <DropdownItem
             onClick={bookmarkItemClickedHandler}
             className="grw-page-control-dropdown-item"
@@ -251,9 +262,8 @@ type PageItemControlSubstanceProps = CommonProps & {
 export const PageItemControlSubstance = (props: PageItemControlSubstanceProps): JSX.Element => {
 
   const {
-    pageId, pageInfo: presetPageInfo, fetchOnInit,
-    children,
-    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickPathRecoveryMenuItem,
+    pageId, pageInfo: presetPageInfo, fetchOnInit, children, onClickBookmarkMenuItem, onClickRenameMenuItem,
+    onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickPathRecoveryMenuItem,
   } = props;
 
   const [isOpen, setIsOpen] = useState(false);

+ 127 - 0
packages/app/src/components/Common/ImageCropModal.tsx

@@ -0,0 +1,127 @@
+import React, {
+  FC, useCallback, useEffect, useState,
+} from 'react';
+
+import canvasToBlob from 'async-canvas-to-blob';
+import { useTranslation } from 'react-i18next';
+import ReactCrop from 'react-image-crop';
+import {
+  Modal,
+  ModalHeader,
+  ModalBody,
+  ModalFooter,
+} from 'reactstrap';
+
+import { toastError } from '~/client/util/apiNotification';
+import loggerFactory from '~/utils/logger';
+import 'react-image-crop/dist/ReactCrop.css';
+
+const logger = loggerFactory('growi:ImageCropModal');
+
+interface ICropOptions {
+  aspect: number
+  unit: string,
+  x: number
+  y: number
+  width: number,
+  height: number,
+}
+
+type CropOptions = ICropOptions | null
+
+type Props = {
+  isShow: boolean,
+  src: string | ArrayBuffer | null,
+  onModalClose: () => void,
+  onCropCompleted: (res: any) => void,
+  isCircular: boolean,
+}
+const ImageCropModal: FC<Props> = (props: Props) => {
+
+  const {
+    isShow, src, onModalClose, onCropCompleted, isCircular,
+  } = props;
+
+  const [imageRef, setImageRef] = useState<HTMLImageElement>();
+  const [cropOptions, setCropOtions] = useState<CropOptions>(null);
+  const { t } = useTranslation();
+  const reset = useCallback(() => {
+    if (imageRef) {
+      const size = Math.min(imageRef.width, imageRef.height);
+      setCropOtions({
+        aspect: 1,
+        unit: 'px',
+        x: imageRef.width / 2 - size / 2,
+        y: imageRef.height / 2 - size / 2,
+        width: size,
+        height: size,
+      });
+    }
+  }, [imageRef]);
+
+  useEffect(() => {
+    document.body.style.position = 'static';
+    reset();
+  }, [reset]);
+
+  const onImageLoaded = (image) => {
+    setImageRef(image);
+    reset();
+    return false;
+  };
+
+
+  const onCropChange = (crop) => {
+    setCropOtions(crop);
+  };
+
+  const getCroppedImg = async(image, crop) => {
+    const canvas = document.createElement('canvas');
+    const scaleX = image.naturalWidth / image.width;
+    const scaleY = image.naturalHeight / image.height;
+    canvas.width = crop.width;
+    canvas.height = crop.height;
+    const ctx = canvas.getContext('2d');
+    ctx?.drawImage(image, crop.x * scaleX, crop.y * scaleY, crop.width * scaleX, crop.height * scaleY, 0, 0, crop.width, crop.height);
+    try {
+      const blob = await canvasToBlob(canvas);
+      return blob;
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(new Error('Failed to draw image'));
+    }
+  };
+
+  const crop = async() => {
+    // crop immages
+    if (imageRef && cropOptions?.width && cropOptions.height) {
+      const result = await getCroppedImg(imageRef, cropOptions);
+      onCropCompleted(result);
+    }
+  };
+
+  return (
+    <Modal isOpen={isShow} toggle={onModalClose}>
+      <ModalHeader tag="h4" toggle={onModalClose} className="bg-info text-light">
+        {t('crop_image_modal.image_crop')}
+      </ModalHeader>
+      <ModalBody className="my-4">
+        <ReactCrop src={src} crop={cropOptions} onImageLoaded={onImageLoaded} onChange={onCropChange} circularCrop={isCircular} />
+      </ModalBody>
+      <ModalFooter>
+        <button type="button" className="btn btn-outline-danger rounded-pill mr-auto" onClick={reset}>
+          {t('crop_image_modal.reset')}
+        </button>
+        <button type="button" className="btn btn-outline-secondary rounded-pill mr-2" onClick={onModalClose}>
+          {t('crop_image_modal.cancel')}
+        </button>
+        <button type="button" className="btn btn-outline-primary rounded-pill" onClick={crop}>
+          {t('crop_image_modal.crop')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+export default ImageCropModal;

+ 30 - 7
packages/app/src/components/Drawio.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useCallback, useEffect, useMemo, useRef,
+  useCallback, useEffect, useMemo, useRef, useState,
 } from 'react';
 
 import EventEmitter from 'events';
@@ -8,35 +8,55 @@ import { useTranslation } from 'react-i18next';
 import { debounce } from 'throttle-debounce';
 
 import { CustomWindow } from '~/interfaces/global';
-import { IGraphViewer } from '~/interfaces/graph-viewer';
+import { IGraphViewer, isGraphViewer } from '~/interfaces/graph-viewer';
 
 import NotAvailableForGuest from './NotAvailableForGuest';
 
 type Props = {
+  GraphViewer: IGraphViewer,
   drawioContent: string,
   rangeLineNumberOfMarkdown: { beginLineNumber: number, endLineNumber: number },
   isPreview?: boolean,
 }
 
+// It calls callback when GraphViewer is not null.
+// eslint-disable-next-line @typescript-eslint/ban-types
+const waitForGraphViewer = async(callback: Function) => {
+  const MAX_WAIT_COUNT = 10; // no reason for 10
+
+  for (let i = 0; i < MAX_WAIT_COUNT; i++) {
+    if (isGraphViewer((window as CustomWindow).GraphViewer)) {
+      callback((window as CustomWindow).GraphViewer);
+      break;
+    }
+    // Sleep 500 ms
+    // eslint-disable-next-line no-await-in-loop
+    await new Promise<void>(r => setTimeout(() => r(), 500));
+  }
+};
+
 const Drawio = (props: Props): JSX.Element => {
 
   const { t } = useTranslation();
 
+  // Wrap with a function since GraphViewer is a function.
+  // This applies when call setGraphViewer as well.
+  const [GraphViewer, setGraphViewer] = useState<IGraphViewer | undefined>(() => (window as CustomWindow).GraphViewer);
+
   const { drawioContent, rangeLineNumberOfMarkdown, isPreview } = props;
 
   // const { open: openDrawioModal } = useDrawioModalForPage();
 
   const drawioContainerRef = useRef<HTMLDivElement>(null);
 
-  const globalEmitter: EventEmitter = useMemo(() => (window as CustomWindow).globalEmitter, []);
-  const GraphViewer: IGraphViewer = useMemo(() => (window as CustomWindow).GraphViewer, []);
+  const globalEmitter: EventEmitter = (window as CustomWindow).globalEmitter;
 
   const editButtonClickHandler = useCallback(() => {
     const { beginLineNumber, endLineNumber } = rangeLineNumberOfMarkdown;
     globalEmitter.emit('launchDrawioModal', beginLineNumber, endLineNumber);
   }, [rangeLineNumberOfMarkdown, globalEmitter]);
 
-  const renderDrawio = useCallback(() => {
+  const renderDrawio = useCallback((GraphViewer: IGraphViewer) => {
     if (drawioContainerRef.current == null) {
       return;
     }
@@ -51,16 +71,19 @@ const Drawio = (props: Props): JSX.Element => {
         GraphViewer.createViewerForElement(div);
       }
     }
-  }, [GraphViewer]);
+  }, [drawioContainerRef]);
 
   const renderDrawioWithDebounce = useMemo(() => debounce(200, renderDrawio), [renderDrawio]);
 
   useEffect(() => {
     if (GraphViewer == null) {
+      waitForGraphViewer((gv: IGraphViewer) => {
+        setGraphViewer(() => gv);
+      });
       return;
     }
 
-    renderDrawioWithDebounce();
+    renderDrawioWithDebounce(GraphViewer);
   }, [renderDrawioWithDebounce, GraphViewer]);
 
   return (

+ 1 - 11
packages/app/src/components/LikeButtons.tsx

@@ -85,14 +85,4 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
 
 };
 
-/**
- * Wrapper component for using unstated
- */
-const LikeButtonsUnstatedWrapper = withUnstatedContainers(LikeButtons, [AppContainer]);
-
-// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-const LikeButtonsWrapper = (props) => {
-  return <LikeButtonsUnstatedWrapper {...props}></LikeButtonsUnstatedWrapper>;
-};
-
-export default LikeButtonsWrapper;
+export default LikeButtons;

+ 0 - 125
packages/app/src/components/Me/ImageCropModal.jsx

@@ -1,125 +0,0 @@
-import React from 'react';
-
-import canvasToBlob from 'async-canvas-to-blob';
-import PropTypes from 'prop-types';
-import { useTranslation } from 'react-i18next';
-import ReactCrop from 'react-image-crop';
-import {
-  Modal,
-  ModalHeader,
-  ModalBody,
-  ModalFooter,
-} from 'reactstrap';
-
-import 'react-image-crop/dist/ReactCrop.css';
-import { toastError } from '~/client/util/apiNotification';
-import loggerFactory from '~/utils/logger';
-
-
-const logger = loggerFactory('growi:ImageCropModal');
-
-class ImageCropModal extends React.Component {
-
-  // demo: https://codesandbox.io/s/72py4jlll6
-  constructor(props) {
-    super();
-    this.state = {
-      crop: null,
-      imageRef: null,
-    };
-    this.onImageLoaded = this.onImageLoaded.bind(this);
-    this.onCropChange = this.onCropChange.bind(this);
-    this.getCroppedImg = this.getCroppedImg.bind(this);
-    this.crop = this.crop.bind(this);
-    this.reset = this.reset.bind(this);
-    this.imageRef = null;
-  }
-
-  onImageLoaded(image) {
-    this.setState({ imageRef: image }, () => this.reset());
-    return false; // Return false when setting crop state in here.
-  }
-
-  onCropChange(crop) {
-    this.setState({ crop });
-  }
-
-  async getCroppedImg(image, crop, fileName) {
-    const canvas = document.createElement('canvas');
-    const scaleX = image.naturalWidth / image.width;
-    const scaleY = image.naturalHeight / image.height;
-    canvas.width = crop.width;
-    canvas.height = crop.height;
-    const ctx = canvas.getContext('2d');
-    ctx.drawImage(image, crop.x * scaleX, crop.y * scaleY, crop.width * scaleX, crop.height * scaleY, 0, 0, crop.width, crop.height);
-    try {
-      const blob = await canvasToBlob(canvas);
-      return blob;
-    }
-    catch (err) {
-      logger.error(err);
-      toastError(new Error('Failed to draw image'));
-    }
-  }
-
-  async crop() {
-    // crop immages
-    if (this.state.imageRef && this.state.crop.width && this.state.crop.height) {
-      const croppedImage = await this.getCroppedImg(this.state.imageRef, this.state.crop, '/images/icons/user');
-      this.props.onCropCompleted(croppedImage);
-    }
-  }
-
-  reset() {
-    const size = Math.min(this.state.imageRef.width, this.state.imageRef.height);
-    this.setState({
-      crop: {
-        aspect: 1,
-        unit: 'px',
-        x: this.state.imageRef.width / 2 - size / 2,
-        y: this.state.imageRef.height / 2 - size / 2,
-        width: size,
-        height: size,
-      },
-    });
-  }
-
-  render() {
-    return (
-      <Modal isOpen={this.props.show} toggle={this.props.onModalClose}>
-        <ModalHeader tag="h4" toggle={this.props.onModalClose} className="bg-info text-light">
-          Image Crop
-        </ModalHeader>
-        <ModalBody className="my-4">
-          <ReactCrop circularCrop src={this.props.src} crop={this.state.crop} onImageLoaded={this.onImageLoaded} onChange={this.onCropChange} />
-        </ModalBody>
-        <ModalFooter>
-          <button type="button" className="btn btn-outline-danger rounded-pill mr-auto" onClick={this.reset}>
-            Reset
-          </button>
-          <button type="button" className="btn btn-outline-secondary rounded-pill mr-2" onClick={this.props.onModalClose}>
-            Cancel
-          </button>
-          <button type="button" className="btn btn-outline-primary rounded-pill" onClick={this.crop}>
-            Crop
-          </button>
-        </ModalFooter>
-      </Modal>
-    );
-  }
-
-}
-
-ImageCropModal.propTypes = {
-  show: PropTypes.bool.isRequired,
-  src: PropTypes.string,
-  onModalClose: PropTypes.func.isRequired,
-  onCropCompleted: PropTypes.func.isRequired,
-};
-
-const ImageCropModalWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <ImageCropModal t={t} {...props} />;
-};
-
-export default ImageCropModalWrapperFC;

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

@@ -6,10 +6,10 @@ import { useTranslation } from 'react-i18next';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiPost, apiPostForm } from '~/client/util/apiv1-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
+import ImageCropModal from '~/components/Common/ImageCropModal';
 import { useCurrentUser } from '~/stores/context';
 import { generateGravatarSrc, GRAVATAR_DEFAULT } from '~/utils/gravatar';
 
-import ImageCropModal from './ImageCropModal';
 
 const DEFAULT_IMAGE = '/images/icons/user.svg';
 
@@ -155,10 +155,11 @@ const ProfileImageSettings = (): JSX.Element => {
       </div>
 
       <ImageCropModal
-        show={showImageCropModal}
+        isShow={showImageCropModal}
         src={imageCropSrc}
         onModalClose={() => setShowImageCropModal(false)}
         onCropCompleted={cropCompletedHandler}
+        isCircular
       />
 
       <div className="row my-3">

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

@@ -1,19 +1,19 @@
 import React, {
   FC, useState, useCallback, useRef,
 } from 'react';
-import { useTranslation } from 'react-i18next';
+
 import assert from 'assert';
 
-import AppContainer from '~/client/services/AppContainer';
+import { useTranslation } from 'react-i18next';
+
 import { IFocusable } from '~/client/interfaces/focusable';
+import AppContainer from '~/client/services/AppContainer';
+import { IPageWithSearchMeta } from '~/interfaces/search';
+import { useCurrentPagePath } from '~/stores/context';
 import { useGlobalSearchFormRef } from '~/stores/ui';
-import { IPageSearchMeta } from '~/interfaces/search';
-import { IPageWithMeta } from '~/interfaces/page';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
 
 import SearchForm from '../SearchForm';
-import { useCurrentPagePath } from '~/stores/context';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 type Props = {
@@ -36,7 +36,7 @@ const GlobalSearch: FC<Props> = (props: Props) => {
 
   const { data: currentPagePath } = useCurrentPagePath();
 
-  const gotoPage = useCallback((data: IPageWithMeta<IPageSearchMeta>[]) => {
+  const gotoPage = useCallback((data: IPageWithSearchMeta[]) => {
     assert(data.length > 0);
 
     const page = data[0].data; // should be single page selected

+ 1 - 0
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -126,6 +126,7 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
       <DropdownItem
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
         disabled={isGuestUser || isSharedUser || isLinkSharingDisabled}
+        data-testid="open-page-accessories-modal-btn-with-share-link-management-data-tab"
         className="grw-page-control-dropdown-item"
       >
         <span className="grw-page-control-dropdown-icon">

+ 15 - 3
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -106,12 +106,24 @@ const Confidential: FC<ConfidentialProps> = memo((props: ConfidentialProps) => {
   );
 });
 
+interface NavbarLogoProps {
+  logoSrc?: string,
+}
+const GrowiNavbarLogo: FC<NavbarLogoProps> = memo((props: NavbarLogoProps) => {
+
+  const { logoSrc } = props;
+  return logoSrc != null
+    ? (<img src={logoSrc} className="picture picture-lg p-2 mx-2" id="settingBrandLogo" width="32" />)
+    : <GrowiLogo />;
+
+});
 
 const GrowiNavbar = (props) => {
 
   const { appContainer } = props;
-  const { crowi, isSearchServiceConfigured } = appContainer.config;
-
+  const {
+    crowi, isSearchServiceConfigured, customizedLogoSrc,
+  } = appContainer.config;
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
   const { data: isSearchPage } = useIsSearchPage();
 
@@ -120,7 +132,7 @@ const GrowiNavbar = (props) => {
       {/* Brand Logo  */}
       <div className="navbar-brand mr-0">
         <a className="grw-logo d-block" href="/">
-          <GrowiLogo />
+          <GrowiNavbarLogo logoSrc={customizedLogoSrc} />
         </a>
       </div>
 

+ 71 - 5
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -1,8 +1,14 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useMemo } from 'react';
 
-import { toggleBookmark, toggleLike, toggleSubscribe } from '~/client/services/page-operation';
+import { useTranslation } from 'react-i18next';
+import { DropdownItem } from 'reactstrap';
+
+import {
+  toggleBookmark, toggleLike, toggleSubscribe, updateContentWidth,
+} from '~/client/services/page-operation';
+import { toastError } from '~/client/util/apiNotification';
 import {
-  IPageInfoAll, IPageToDeleteWithMeta, IPageToRenameWithMeta, isIPageInfoForEntity, isIPageInfoForOperation,
+  IPageInfoForOperation, IPageToDeleteWithMeta, IPageToRenameWithMeta, isIPageInfoForEntity, isIPageInfoForOperation,
 } from '~/interfaces/page';
 import { useIsGuestUser } from '~/stores/context';
 import { IPageForPageDuplicateModal } from '~/stores/modal';
@@ -19,6 +25,43 @@ import SubscribeButton from '../SubscribeButton';
 import SeenUserInfo from '../User/SeenUserInfo';
 
 
+type WideViewMenuItemProps = AdditionalMenuItemsRendererProps & {
+  onClickMenuItem: (newValue: boolean) => void,
+}
+
+const WideViewMenuItem = (props: WideViewMenuItemProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    pageInfo, onClickMenuItem,
+  } = props;
+
+  if (!isIPageInfoForEntity(pageInfo)) {
+    return <></>;
+  }
+
+  return (
+    <DropdownItem
+      onClick={() => onClickMenuItem(!pageInfo.expandContentWidth)}
+      className="grw-page-control-dropdown-item"
+    >
+      <div className="custom-control custom-switch ml-1">
+        <input
+          id="switchContentWidth"
+          className="custom-control-input"
+          type="checkbox"
+          checked={pageInfo.expandContentWidth}
+          onChange={() => {}}
+        />
+        <label className="custom-control-label" htmlFor="switchContentWidth">
+          { t('wide_view') }
+        </label>
+      </div>
+    </DropdownItem>
+  );
+};
+
+
 type CommonProps = {
   isCompactMode?: boolean,
   disableSeenUserInfoPopover?: boolean,
@@ -35,7 +78,7 @@ type SubNavButtonsSubstanceProps = CommonProps & {
   shareLinkId?: string | null,
   revisionId: string | null,
   path?: string | null,
-  pageInfo: IPageInfoAll,
+  pageInfo: IPageInfoForOperation,
 }
 
 const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element => {
@@ -140,11 +183,33 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
     onClickDeleteMenuItem(pageToDelete);
   }, [onClickDeleteMenuItem, pageId, pageInfo, path, revisionId]);
 
+  const switchContentWidthClickHandler = useCallback(async(newValue: boolean) => {
+    if (isGuestUser == null || isGuestUser) {
+      return;
+    }
+    if (!isIPageInfoForEntity(pageInfo)) {
+      return;
+    }
+    try {
+      await updateContentWidth(pageId, newValue);
+      mutatePageInfo();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
+
+  const wideviewMenuItemRenderer = useMemo(() => {
+    if (!isIPageInfoForEntity(pageInfo)) {
+      return undefined;
+    }
+    return props => <WideViewMenuItem {...props} onClickMenuItem={switchContentWidthClickHandler} />;
+  }, [pageInfo, switchContentWidthClickHandler]);
+
   if (!isIPageInfoForOperation(pageInfo)) {
     return <></>;
   }
 
-
   const {
     sumOfLikers, sumOfSeenUsers, isLiked, bookmarkCount, isBookmarked,
   } = pageInfo;
@@ -192,6 +257,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
           pageInfo={pageInfo}
           isEnableActions={!isGuestUser}
           forceHideMenuItems={forceHideMenuItemsWithBookmark}
+          additionalMenuItemOnTopRenderer={wideviewMenuItemRenderer}
           additionalMenuItemRenderer={additionalMenuItemRenderer}
           onClickRenameMenuItem={renameMenuItemClickHandler}
           onClickDuplicateMenuItem={duplicateMenuItemClickHandler}

+ 5 - 1
packages/app/src/components/Page/FixPageGrantAlert.tsx

@@ -76,6 +76,10 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
       return t('fix_page_grant.modal.grant_label.isForbidden');
     }
 
+    if (grantData.grant === 1) {
+      return t('fix_page_grant.modal.grant_label.public');
+    }
+
     if (grantData.grant === 4) {
       return t('fix_page_grant.modal.radio_btn.only_me');
     }
@@ -87,7 +91,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
       return `${t('fix_page_grant.modal.radio_btn.grant_group')}: (${grantData.grantedGroup.name})`;
     }
 
-    throw Error('cannnot get grant label'); // this error can't be throwed
+    throw Error('cannot get grant label'); // this error can't be throwed
   }, [t]);
 
   const renderGrantDataLabel = useCallback(() => {

+ 14 - 1
packages/app/src/components/Page/RevisionRenderer.tsx

@@ -2,6 +2,7 @@ import React, {
   useCallback, useEffect, useMemo, useState,
 } from 'react';
 
+import AppContainer from '~/client/services/AppContainer';
 import { blinkElem } from '~/client/util/blink-section-header';
 import { addSmoothScrollEvent } from '~/client/util/smooth-scroll';
 import { CustomWindow } from '~/interfaces/global';
@@ -9,6 +10,8 @@ import GrowiRenderer from '~/services/renderer/growi-renderer';
 import { useEditorSettings } from '~/stores/editor';
 import loggerFactory from '~/utils/logger';
 
+import { withUnstatedContainers } from '../UnstatedUtils';
+
 import RevisionBody from './RevisionBody';
 
 
@@ -84,6 +87,7 @@ function getHighlightedBody(body: string, _keywords: string | string[]): string
 
 
 type Props = {
+  appContainer: AppContainer,
   growiRenderer: GrowiRenderer,
   markdown: string,
   pagePath: string,
@@ -154,9 +158,13 @@ const RevisionRenderer = (props: Props): JSX.Element => {
 
   }, [currentRenderingContext, interceptorManager, renderHtml]);
 
+  const config = props.appContainer.getConfig();
+  const isMathJaxEnabled = !!config.env.MATHJAX;
+
   return (
     <RevisionBody
       html={html}
+      isMathJaxEnabled={isMathJaxEnabled}
       additionalClassName={props.additionalClassName}
       renderMathJaxOnInit
     />
@@ -164,4 +172,9 @@ const RevisionRenderer = (props: Props): JSX.Element => {
 
 };
 
-export default RevisionRenderer;
+/**
+   * Wrapper component for using unstated
+   */
+const RevisionRendererWrapper = withUnstatedContainers(RevisionRenderer, [AppContainer]);
+
+export default RevisionRendererWrapper;

+ 0 - 108
packages/app/src/components/PageAccessoriesModalControl.jsx

@@ -1,108 +0,0 @@
-import React, { Fragment, useMemo } from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'react-i18next';
-import { UncontrolledTooltip } from 'reactstrap';
-
-import { useCurrentPageId } from '~/stores/context';
-
-import AttachmentIcon from './Icons/AttachmentIcon';
-import HistoryIcon from './Icons/HistoryIcon';
-import PageListIcon from './Icons/PageListIcon';
-import ShareLinkIcon from './Icons/ShareLinkIcon';
-import TimeLineIcon from './Icons/TimeLineIcon';
-import { withUnstatedContainers } from './UnstatedUtils';
-
-
-const PageAccessoriesModalControl = (props) => {
-  const { t } = useTranslation();
-  const {
-    pageAccessoriesContainer, isGuestUser, isSharedUser,
-  } = props;
-  const isLinkSharingDisabled = pageAccessoriesContainer.appContainer.config.disableLinkSharing;
-
-  const { data: pageId } = useCurrentPageId();
-
-  const accessoriesBtnList = useMemo(() => {
-    return [
-      {
-        name: 'pagelist',
-        Icon: <PageListIcon />,
-        disabled: isSharedUser,
-        i18n: t('page_list'),
-      },
-      {
-        name: 'timeline',
-        Icon: <TimeLineIcon />,
-        disabled: isSharedUser,
-        i18n: t('Timeline View'),
-      },
-      {
-        name: 'pageHistory',
-        Icon: <HistoryIcon />,
-        disabled: isGuestUser || isSharedUser,
-        i18n: t('History'),
-      },
-      {
-        name: 'attachment',
-        Icon: <AttachmentIcon />,
-        i18n: t('attachment_data'),
-      },
-      {
-        name: 'shareLink',
-        Icon: <ShareLinkIcon />,
-        disabled: isGuestUser || isSharedUser || isLinkSharingDisabled,
-        i18n: t('share_links.share_link_management'),
-      },
-    ];
-  }, [t, isGuestUser, isSharedUser, isLinkSharingDisabled]);
-
-  return (
-    <div className="grw-page-accessories-control d-flex flex-nowrap align-items-center justify-content-end justify-content-lg-between">
-      {accessoriesBtnList.map((accessory) => {
-
-        let tooltipMessage;
-        if (accessory.disabled) {
-          tooltipMessage = t('Not available for guest');
-          if (accessory.name === 'shareLink' && isLinkSharingDisabled) {
-            tooltipMessage = t('Link sharing is disabled');
-          }
-        }
-        else {
-          tooltipMessage = accessory.i18n;
-        }
-
-        return (
-          <Fragment key={accessory.name}>
-            <div id={`shareLink-btn-wrapper-for-tooltip-for-${accessory.name}`}>
-              <button
-                type="button"
-                className={`btn btn-link grw-btn-page-accessories ${accessory.disabled ? 'disabled' : ''}`}
-                onClick={() => pageAccessoriesContainer.openPageAccessoriesModal(accessory.name)}
-              >
-                {accessory.Icon}
-              </button>
-            </div>
-            <UncontrolledTooltip placement="top" target={`shareLink-btn-wrapper-for-tooltip-for-${accessory.name}`} fade={false}>
-              {tooltipMessage}
-            </UncontrolledTooltip>
-          </Fragment>
-        );
-      })}
-    </div>
-  );
-};
-
-PageAccessoriesModalControl.propTypes = {
-  pageAccessoriesContainer: PropTypes.any,
-
-  isGuestUser: PropTypes.bool.isRequired,
-  isSharedUser: PropTypes.bool.isRequired,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PageAccessoriesModalControlWrapper = withUnstatedContainers(PageAccessoriesModalControl, []);
-
-export default PageAccessoriesModalControlWrapper;

+ 0 - 404
packages/app/src/components/PageEditor/Editor.jsx

@@ -1,404 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import Dropzone from 'react-dropzone';
-import {
-  Modal, ModalHeader, ModalBody,
-} from 'reactstrap';
-
-import { useDefaultIndentSize } from '~/stores/context';
-import { useEditorSettings } from '~/stores/editor';
-
-import AbstractEditor from './AbstractEditor';
-import Cheatsheet from './Cheatsheet';
-import CodeMirrorEditor from './CodeMirrorEditor';
-import pasteHelper from './PasteHelper';
-import TextAreaEditor from './TextAreaEditor';
-
-
-class Editor extends AbstractEditor {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isComponentDidMount: false,
-      dropzoneActive: false,
-      isUploading: false,
-      isCheatsheetModalShown: false,
-    };
-
-    this.getEditorSubstance = this.getEditorSubstance.bind(this);
-
-    this.pasteFilesHandler = this.pasteFilesHandler.bind(this);
-
-    this.dragEnterHandler = this.dragEnterHandler.bind(this);
-    this.dragLeaveHandler = this.dragLeaveHandler.bind(this);
-    this.dropHandler = this.dropHandler.bind(this);
-
-    this.showMarkdownHelp = this.showMarkdownHelp.bind(this);
-    this.addAttachmentHandler = this.addAttachmentHandler.bind(this);
-
-    this.getAcceptableType = this.getAcceptableType.bind(this);
-    this.getDropzoneClassName = this.getDropzoneClassName.bind(this);
-    this.renderDropzoneOverlay = this.renderDropzoneOverlay.bind(this);
-  }
-
-  componentDidMount() {
-    this.setState({ isComponentDidMount: true });
-  }
-
-  getEditorSubstance() {
-    return this.props.isMobile
-      ? this.taEditor
-      : this.cmEditor;
-  }
-
-  /**
-   * @inheritDoc
-   */
-  forceToFocus() {
-    this.getEditorSubstance().forceToFocus();
-  }
-
-  /**
-   * @inheritDoc
-   */
-  setValue(newValue) {
-    this.getEditorSubstance().setValue(newValue);
-  }
-
-  /**
-   * @inheritDoc
-   */
-  setGfmMode(bool) {
-    this.getEditorSubstance().setGfmMode(bool);
-  }
-
-  /**
-   * @inheritDoc
-   */
-  setCaretLine(line) {
-    this.getEditorSubstance().setCaretLine(line);
-  }
-
-  /**
-   * @inheritDoc
-   */
-  setScrollTopByLine(line) {
-    this.getEditorSubstance().setScrollTopByLine(line);
-  }
-
-  /**
-   * @inheritDoc
-   */
-  insertText(text) {
-    this.getEditorSubstance().insertText(text);
-  }
-
-  /**
-   * remove overlay and set isUploading to false
-   */
-  terminateUploadingState() {
-    this.setState({
-      dropzoneActive: false,
-      isUploading: false,
-    });
-  }
-
-  /**
-   * dispatch onUpload event
-   */
-  dispatchUpload(files) {
-    if (this.props.onUpload != null) {
-      this.props.onUpload(files);
-    }
-  }
-
-  /**
-   * get acceptable(uploadable) file type
-   */
-  getAcceptableType() {
-    let accept = 'null'; // reject all
-    if (this.props.isUploadable) {
-      if (!this.props.isUploadableFile) {
-        accept = 'image/*'; // image only
-      }
-      else {
-        accept = ''; // allow all
-      }
-    }
-
-    return accept;
-  }
-
-  pasteFilesHandler(event) {
-    const items = event.clipboardData.items || event.clipboardData.files || [];
-
-    // abort if length is not 1
-    if (items.length < 1) {
-      return;
-    }
-
-    for (let i = 0; i < items.length; i++) {
-      try {
-        const file = items[i].getAsFile();
-        // check file type (the same process as Dropzone)
-        if (file != null && pasteHelper.isAcceptableType(file, this.getAcceptableType())) {
-          this.dispatchUpload(file);
-          this.setState({ isUploading: true });
-        }
-      }
-      catch (e) {
-        this.logger.error(e);
-      }
-    }
-  }
-
-  dragEnterHandler(event) {
-    const dataTransfer = event.dataTransfer;
-
-    // do nothing if contents is not files
-    if (!dataTransfer.types.includes('Files')) {
-      return;
-    }
-
-    this.setState({ dropzoneActive: true });
-  }
-
-  dragLeaveHandler() {
-    this.setState({ dropzoneActive: false });
-  }
-
-  dropHandler(accepted, rejected) {
-    // rejected
-    if (accepted.length !== 1) { // length should be 0 or 1 because `multiple={false}` is set
-      this.setState({ dropzoneActive: false });
-      return;
-    }
-
-    const file = accepted[0];
-    this.dispatchUpload(file);
-    this.setState({ isUploading: true });
-  }
-
-  showMarkdownHelp() {
-    this.setState({ isCheatsheetModalShown: true });
-  }
-
-  addAttachmentHandler() {
-    this.dropzone.open();
-  }
-
-  getDropzoneClassName(isDragAccept, isDragReject) {
-    let className = 'dropzone';
-    if (!this.props.isUploadable) {
-      className += ' dropzone-unuploadable';
-    }
-    else {
-      className += ' dropzone-uploadable';
-
-      if (this.props.isUploadableFile) {
-        className += ' dropzone-uploadablefile';
-      }
-    }
-
-    // uploading
-    if (this.state.isUploading) {
-      className += ' dropzone-uploading';
-    }
-
-    if (isDragAccept) {
-      className += ' dropzone-accepted';
-    }
-
-    if (isDragReject) {
-      className += ' dropzone-rejected';
-    }
-
-    return className;
-  }
-
-  renderDropzoneOverlay() {
-    return (
-      <div className="overlay overlay-dropzone-active">
-        {this.state.isUploading
-          && (
-            <span className="overlay-content">
-              <div className="speeding-wheel d-inline-block"></div>
-              <span className="sr-only">Uploading...</span>
-            </span>
-          )
-        }
-        {!this.state.isUploading && <span className="overlay-content"></span>}
-      </div>
-    );
-  }
-
-  renderNavbar() {
-    return (
-      <div className="m-0 navbar navbar-default navbar-editor" style={{ minHeight: 'unset' }}>
-        <ul className="pl-2 nav nav-navbar">
-          { this.getNavbarItems() != null && this.getNavbarItems().map((item, idx) => {
-            // eslint-disable-next-line react/no-array-index-key
-            return <li key={`navbarItem-${idx}`}>{item}</li>;
-          }) }
-        </ul>
-      </div>
-    );
-  }
-
-  getNavbarItems() {
-    // set navbar items(react elements) here that are common in CodeMirrorEditor or TextAreaEditor
-    const navbarItems = [];
-
-    // concat common items and items specific to CodeMirrorEditor or TextAreaEditor
-    return navbarItems.concat(this.getEditorSubstance().getNavbarItems());
-  }
-
-  renderCheatsheetModal() {
-    const hideCheatsheetModal = () => {
-      this.setState({ isCheatsheetModalShown: false });
-    };
-
-    return (
-      <Modal isOpen={this.state.isCheatsheetModalShown} toggle={hideCheatsheetModal} className="modal-gfm-cheatsheet">
-        <ModalHeader tag="h4" toggle={hideCheatsheetModal} className="bg-primary text-light">
-          <i className="icon-fw icon-question" />Markdown help
-        </ModalHeader>
-        <ModalBody>
-          <Cheatsheet />
-        </ModalBody>
-      </Modal>
-    );
-  }
-
-
-  render() {
-    const flexContainer = {
-      height: '100%',
-      display: 'flex',
-      flexDirection: 'column',
-    };
-
-    const {
-      isMobile,
-      indentSize,
-    } = this.props;
-
-    return (
-      <>
-        <div style={flexContainer} className="editor-container">
-          <Dropzone
-            ref={(c) => { this.dropzone = c }}
-            accept={this.getAcceptableType()}
-            noClick
-            noKeyboard
-            multiple={false}
-            onDragLeave={this.dragLeaveHandler}
-            onDrop={this.dropHandler}
-          >
-            {({
-              getRootProps,
-              getInputProps,
-              isDragAccept,
-              isDragReject,
-            }) => {
-              return (
-                <div className={this.getDropzoneClassName(isDragAccept, isDragReject)} {...getRootProps()}>
-                  { this.state.dropzoneActive && this.renderDropzoneOverlay() }
-
-                  { this.state.isComponentDidMount && this.renderNavbar() }
-
-                  {/* for PC */}
-                  { !isMobile && (
-                    // eslint-disable-next-line arrow-body-style
-                    <CodeMirrorEditor
-                      ref={(c) => { this.cmEditor = c }}
-                      indentSize={indentSize}
-                      onPasteFiles={this.pasteFilesHandler}
-                      onDragEnter={this.dragEnterHandler}
-                      onMarkdownHelpButtonClicked={this.showMarkdownHelp}
-                      onAddAttachmentButtonClicked={this.addAttachmentHandler}
-                      {...this.props}
-                    />
-                  )}
-
-                  {/* for mobile */}
-                  { isMobile && (
-                    <TextAreaEditor
-                      ref={(c) => { this.taEditor = c }}
-                      onPasteFiles={this.pasteFilesHandler}
-                      onDragEnter={this.dragEnterHandler}
-                      {...this.props}
-                    />
-                  )}
-
-                  <input {...getInputProps()} />
-                </div>
-              );
-            }}
-          </Dropzone>
-
-          { this.props.isUploadable
-            && (
-              <button
-                type="button"
-                className="btn btn-outline-secondary btn-block btn-open-dropzone"
-                onClick={this.addAttachmentHandler}
-              >
-                <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
-                Attach files
-                <span className="d-none d-sm-inline">
-                &nbsp;by dragging &amp; dropping,&nbsp;
-                  <span className="btn-link">selecting them</span>,&nbsp;
-                  or pasting from the clipboard.
-                </span>
-
-              </button>
-            )
-          }
-
-          { this.renderCheatsheetModal() }
-
-        </div>
-      </>
-    );
-  }
-
-}
-
-Editor.propTypes = Object.assign({
-  noCdn: PropTypes.bool,
-  // this value is markdown
-  value: PropTypes.string,
-  isMobile: PropTypes.bool,
-  isUploadable: PropTypes.bool,
-  isUploadableFile: PropTypes.bool,
-  onChange: PropTypes.func,
-  onUpload: PropTypes.func,
-  editorSettings: PropTypes.object.isRequired,
-  indentSize: PropTypes.number,
-}, AbstractEditor.propTypes);
-
-
-const EditorWrapper = React.forwardRef((props, ref) => {
-  const { data: editorSettings } = useEditorSettings();
-  const { data: defaultIndentSize } = useDefaultIndentSize();
-
-  if (editorSettings == null) {
-    return <></>;
-  }
-
-  return (
-    <Editor
-      ref={ref}
-      {...props}
-      editorSettings={editorSettings}
-      // eslint-disable-next-line react/prop-types
-      indentSize={props.indentSize ?? defaultIndentSize}
-    />
-  );
-});
-
-export default EditorWrapper;

+ 362 - 0
packages/app/src/components/PageEditor/Editor.tsx

@@ -0,0 +1,362 @@
+import React, {
+  useState, useRef, useImperativeHandle, useCallback, useMemo,
+} from 'react';
+
+import Dropzone from 'react-dropzone';
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody,
+} from 'reactstrap';
+
+import { toastError } from '~/client/util/apiNotification';
+import { useDefaultIndentSize } from '~/stores/context';
+import { useEditorSettings } from '~/stores/editor';
+import { useIsMobile } from '~/stores/ui';
+
+import { IEditorMethods } from '../../interfaces/editor-methods';
+
+import Cheatsheet from './Cheatsheet';
+import CodeMirrorEditor from './CodeMirrorEditor';
+import pasteHelper from './PasteHelper';
+import TextAreaEditor from './TextAreaEditor';
+
+
+type EditorPropsType = {
+  value?: string,
+  isGfmMode?: boolean,
+  noCdn?: boolean,
+  isUploadable?: boolean,
+  isUploadableFile?: boolean,
+  onChange?: () => void,
+  onUpload?: (file) => void,
+  indentSize?: number,
+  onScrollCursorIntoView?: (line: number) => void,
+  onSave?: () => Promise<void>,
+  onPasteFiles?: (event: Event) => void,
+  onCtrlEnter?: (event: Event) => void,
+}
+
+type DropzoneRef = {
+  open: () => void
+}
+
+const Editor = React.forwardRef((props: EditorPropsType, ref): JSX.Element => {
+  const {
+    onUpload, isUploadable, isUploadableFile, indentSize, isGfmMode = true,
+  } = props;
+
+  const [dropzoneActive, setDropzoneActive] = useState(false);
+  const [isUploading, setIsUploading] = useState(false);
+  const [isCheatsheetModalShown, setIsCheatsheetModalShown] = useState(false);
+
+  const { t } = useTranslation();
+  const { data: editorSettings } = useEditorSettings();
+  const { data: defaultIndentSize } = useDefaultIndentSize();
+  const { data: isMobile } = useIsMobile();
+
+  const dropzoneRef = useRef<DropzoneRef>(null);
+  const cmEditorRef = useRef<CodeMirrorEditor>(null);
+  const taEditorRef = useRef<TextAreaEditor>(null);
+
+  const editorSubstance = isMobile ? taEditorRef.current : cmEditorRef.current;
+
+  const methods: Partial<IEditorMethods> = useMemo(() => {
+    return {
+      forceToFocus: () => {
+        if (editorSubstance == null) { return }
+        editorSubstance.forceToFocus();
+      },
+      setValue: (newValue: string) => {
+        if (editorSubstance == null) { return }
+        editorSubstance.setValue(newValue);
+      },
+      setGfmMode: (bool: boolean) => {
+        if (editorSubstance == null) { return }
+        editorSubstance.setGfmMode(bool);
+      },
+      setCaretLine: (line: number) => {
+        if (editorSubstance == null) { return }
+        editorSubstance.setCaretLine(line);
+      },
+      setScrollTopByLine: (line: number) => {
+        if (editorSubstance == null) { return }
+        editorSubstance.setScrollTopByLine(line);
+      },
+      insertText: (text: string) => {
+        if (editorSubstance == null) { return }
+        editorSubstance.insertText(text);
+      },
+      getNavbarItems: (): JSX.Element[] => {
+        if (editorSubstance == null) { return [] }
+        // concat common items and items specific to CodeMirrorEditor or TextAreaEditor
+        const navbarItems = editorSubstance.getNavbarItems() ?? [];
+        return navbarItems;
+      },
+    };
+  }, [editorSubstance]);
+
+  // methods for ref
+  useImperativeHandle(ref, () => ({
+    forceToFocus: methods.forceToFocus,
+    setValue: methods.setValue,
+    setGfmMode: methods.setGfmMode,
+    setCaretLine: methods.setCaretLine,
+    setScrollTopByLine: methods.setScrollTopByLine,
+    insertText: methods.insertText,
+    /**
+   * remove overlay and set isUploading to false
+   */
+    terminateUploadingState: () => {
+      setDropzoneActive(false);
+      setIsUploading(false);
+    },
+  }));
+
+  /**
+   * dispatch onUpload event
+   */
+  const dispatchUpload = useCallback((files) => {
+    if (onUpload != null) {
+      onUpload(files);
+    }
+  }, [onUpload]);
+
+  /**
+   * get acceptable(uploadable) file type
+   */
+  const getAcceptableType = useCallback(() => {
+    let accept = 'null'; // reject all
+    if (isUploadable) {
+      if (!isUploadableFile) {
+        accept = 'image/*'; // image only
+      }
+      else {
+        accept = ''; // allow all
+      }
+    }
+
+    return accept;
+  }, [isUploadable, isUploadableFile]);
+
+  const pasteFilesHandler = useCallback((event) => {
+    const items = event.clipboardData.items || event.clipboardData.files || [];
+
+    toastError(t('toaster.file_upload_failed'));
+
+    // abort if length is not 1
+    if (items.length < 1) {
+      return;
+    }
+
+    for (let i = 0; i < items.length; i++) {
+      try {
+        const file = items[i].getAsFile();
+        // check file type (the same process as Dropzone)
+        if (file != null && pasteHelper.isAcceptableType(file, getAcceptableType())) {
+          dispatchUpload(file);
+          setIsUploading(true);
+        }
+      }
+      catch (e) {
+        toastError(t('toaster.file_upload_failed'));
+      }
+    }
+  }, [dispatchUpload, getAcceptableType, t]);
+
+  const dragEnterHandler = useCallback((event) => {
+    const dataTransfer = event.dataTransfer;
+
+    // do nothing if contents is not files
+    if (!dataTransfer.types.includes('Files')) {
+      return;
+    }
+
+    setDropzoneActive(true);
+  }, []);
+
+  const dropHandler = useCallback((accepted) => {
+    // rejected
+    if (accepted.length !== 1) { // length should be 0 or 1 because `multiple={false}` is set
+      setDropzoneActive(false);
+      return;
+    }
+
+    const file = accepted[0];
+    dispatchUpload(file);
+    setIsUploading(true);
+  }, [dispatchUpload]);
+
+  const addAttachmentHandler = useCallback(() => {
+    if (dropzoneRef.current == null) { return }
+    dropzoneRef.current.open();
+  }, []);
+
+  const getDropzoneClassName = useCallback((isDragAccept: boolean, isDragReject: boolean) => {
+    let className = 'dropzone';
+    if (!isUploadable) {
+      className += ' dropzone-unuploadable';
+    }
+    else {
+      className += ' dropzone-uploadable';
+
+      if (isUploadableFile) {
+        className += ' dropzone-uploadablefile';
+      }
+    }
+
+    // uploading
+    if (isUploading) {
+      className += ' dropzone-uploading';
+    }
+
+    if (isDragAccept) {
+      className += ' dropzone-accepted';
+    }
+
+    if (isDragReject) {
+      className += ' dropzone-rejected';
+    }
+
+    return className;
+  }, [isUploadable, isUploading, isUploadableFile]);
+
+  const renderDropzoneOverlay = useCallback(() => {
+    return (
+      <div className="overlay overlay-dropzone-active">
+        {isUploading
+          && (
+            <span className="overlay-content">
+              <div className="speeding-wheel d-inline-block"></div>
+              <span className="sr-only">Uploading...</span>
+            </span>
+          )
+        }
+        {!isUploading && <span className="overlay-content"></span>}
+      </div>
+    );
+  }, [isUploading]);
+
+  const renderNavbar = useCallback(() => {
+    return (
+      <div className="m-0 navbar navbar-default navbar-editor" style={{ minHeight: 'unset' }}>
+        <ul className="pl-2 nav nav-navbar">
+          { methods.getNavbarItems?.().map((item, idx) => {
+            // eslint-disable-next-line react/no-array-index-key
+            return <li key={`navbarItem-${idx}`}>{item}</li>;
+          }) }
+        </ul>
+      </div>
+    );
+  }, [methods]);
+
+  const renderCheatsheetModal = useCallback(() => {
+    const hideCheatsheetModal = () => {
+      setIsCheatsheetModalShown(false);
+    };
+
+    return (
+      <Modal isOpen={isCheatsheetModalShown} toggle={hideCheatsheetModal} className="modal-gfm-cheatsheet">
+        <ModalHeader tag="h4" toggle={hideCheatsheetModal} className="bg-primary text-light">
+          <i className="icon-fw icon-question" />Markdown help
+        </ModalHeader>
+        <ModalBody>
+          <Cheatsheet />
+        </ModalBody>
+      </Modal>
+    );
+  }, [isCheatsheetModalShown]);
+
+  if (editorSettings == null) {
+    return <></>;
+  }
+
+  const flexContainer: React.CSSProperties = {
+    height: '100%',
+    display: 'flex',
+    flexDirection: 'column',
+  };
+
+  return (
+    <>
+      <div style={flexContainer} className="editor-container">
+        <Dropzone
+          ref={dropzoneRef}
+          accept={getAcceptableType()}
+          noClick
+          noKeyboard
+          multiple={false}
+          onDragLeave={() => { setDropzoneActive(false) }}
+          onDrop={dropHandler}
+        >
+          {({
+            getRootProps,
+            getInputProps,
+            isDragAccept,
+            isDragReject,
+          }) => {
+            return (
+              <div className={getDropzoneClassName(isDragAccept, isDragReject)} {...getRootProps()}>
+                { dropzoneActive && renderDropzoneOverlay() }
+
+                { renderNavbar() }
+
+                {/* for PC */}
+                { !isMobile && (
+                  // eslint-disable-next-line arrow-body-style
+                  <CodeMirrorEditor
+                    ref={cmEditorRef}
+                    indentSize={indentSize ?? defaultIndentSize}
+                    onPasteFiles={pasteFilesHandler}
+                    onDragEnter={dragEnterHandler}
+                    onMarkdownHelpButtonClicked={() => { setIsCheatsheetModalShown(true) }}
+                    onAddAttachmentButtonClicked={addAttachmentHandler}
+                    editorSettings={editorSettings}
+                    isGfmMode={isGfmMode}
+                    {...props}
+                  />
+                )}
+
+                {/* for mobile */}
+                { isMobile && (
+                  <TextAreaEditor
+                    ref={taEditorRef}
+                    onPasteFiles={pasteFilesHandler}
+                    onDragEnter={dragEnterHandler}
+                    {...props}
+                  />
+                )}
+
+                <input {...getInputProps()} />
+              </div>
+            );
+          }}
+        </Dropzone>
+
+        { isUploadable
+          && (
+            <button
+              type="button"
+              className="btn btn-outline-secondary btn-block btn-open-dropzone"
+              onClick={addAttachmentHandler}
+            >
+              <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
+              Attach files
+              <span className="d-none d-sm-inline">
+              &nbsp;by dragging &amp; dropping,&nbsp;
+                <span className="btn-link">selecting them</span>,&nbsp;
+                or pasting from the clipboard.
+              </span>
+
+            </button>
+          )
+        }
+
+        { renderCheatsheetModal() }
+
+      </div>
+    </>
+  );
+});
+
+
+export default Editor;

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

@@ -2,7 +2,7 @@ import React from 'react';
 
 import { useTranslation } from 'react-i18next';
 
-import { IPageWithMeta } from '~/interfaces/page';
+import { IPageInfoForEntity, IPageWithMeta } from '~/interfaces/page';
 import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
 
 import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
@@ -10,15 +10,15 @@ import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import { PageListItemL } from './PageListItemL';
 
 
-type Props = {
-  pages: IPageWithMeta[],
+type Props<M extends IPageInfoForEntity> = {
+  pages: IPageWithMeta<M>[],
   isEnableActions?: boolean,
   forceHideMenuItems?: ForceHideMenuItems,
   onPagesDeleted?: OnDeletedFunction,
   onPagePutBacked?: OnPutBackedFunction,
 }
 
-const PageList = (props: Props): JSX.Element => {
+const PageList = (props: Props<IPageInfoForEntity>): JSX.Element => {
   const { t } = useTranslation();
   const {
     pages, isEnableActions, forceHideMenuItems, onPagesDeleted, onPagePutBacked,

+ 4 - 4
packages/app/src/components/PageList/PageListItemL.tsx

@@ -16,9 +16,9 @@ import urljoin from 'url-join';
 import { ISelectable } from '~/client/interfaces/selectable-all';
 import { bookmark, unbookmark } from '~/client/services/page-operation';
 import {
-  IPageInfoAll, IPageInfoForEntity, IPageInfoForListing, IPageWithMeta, isIPageInfoForListing, isIPageInfoForEntity,
+  IPageInfoAll, isIPageInfoForListing, isIPageInfoForEntity, IPageWithMeta, IPageInfoForListing,
 } from '~/interfaces/page';
-import { IPageSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
+import { IPageSearchMeta, IPageWithSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
 import {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
 } from '~/interfaces/ui';
@@ -33,7 +33,7 @@ import { ForceHideMenuItems, PageItemControl } from '../Common/Dropdown/PageItem
 import PagePathHierarchicalLink from '../PagePathHierarchicalLink';
 
 type Props = {
-  page: IPageWithMeta<IPageInfoForEntity> | IPageWithMeta<IPageSearchMeta> | IPageWithMeta<IPageInfoForListing & IPageSearchMeta>,
+  page: IPageWithSearchMeta | IPageWithMeta<IPageInfoForListing & IPageSearchMeta>,
   isSelected?: boolean, // is item selected(focused)
   isEnableActions?: boolean,
   forceHideMenuItems?: ForceHideMenuItems,
@@ -99,7 +99,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   const lastUpdateDate = format(new Date(pageData.updatedAt), 'yyyy/MM/dd HH:mm:ss');
 
   useEffect(() => {
-    if (isIPageInfoForEntity(pageInfo) && pageInfo != null) {
+    if (isIPageInfoForEntity(pageInfo)) {
       // likerCount
       setLikerCount(pageInfo.likerIds?.length ?? 0);
       // bookmarkCount

+ 3 - 3
packages/app/src/components/SearchForm.tsx

@@ -2,12 +2,12 @@ import React, {
   FC, forwardRef, ForwardRefRenderFunction, useImperativeHandle,
   useRef, useState,
 } from 'react';
+
 import { useTranslation } from 'react-i18next';
 
 import { IFocusable } from '~/client/interfaces/focusable';
 import { TypeaheadProps } from '~/client/interfaces/react-bootstrap-typeahead';
-import { IPageWithMeta } from '~/interfaces/page';
-import { IPageSearchMeta } from '~/interfaces/search';
+import { IPageWithSearchMeta } from '~/interfaces/search';
 
 import SearchTypeahead from './SearchTypeahead';
 
@@ -81,7 +81,7 @@ type Props = TypeaheadProps & {
 
   keywordOnInit?: string,
   disableIncrementalSearch?: boolean,
-  onChange?: (data: IPageWithMeta<IPageSearchMeta>[]) => void,
+  onChange?: (data: IPageWithSearchMeta[]) => void,
   onSubmit?: (input: string) => void,
 };
 

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

@@ -9,7 +9,7 @@ import { exportAsMarkdown } from '~/client/services/page-operation';
 import { toastSuccess } from '~/client/util/apiNotification';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { IPageToDeleteWithMeta, IPageToRenameWithMeta, IPageWithMeta } from '~/interfaces/page';
-import { IPageSearchMeta } from '~/interfaces/search';
+import { IPageWithSearchMeta } from '~/interfaces/search';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import {
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal,
@@ -21,7 +21,7 @@ import { useFullTextSearchTermManager } from '~/stores/search';
 
 
 import AppContainer from '../../client/services/AppContainer';
-import { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
+import { AdditionalMenuItemsRendererProps, ForceHideMenuItems, MenuItemType } from '../Common/Dropdown/PageItemControl';
 import { GrowiSubNavigation } from '../Navbar/GrowiSubNavigation';
 import { SubNavButtons } from '../Navbar/SubNavButtons';
 import RevisionLoader from '../Page/RevisionLoader';
@@ -56,7 +56,7 @@ const MUTATION_OBSERVER_CONFIG = { childList: true, subtree: true };
 
 type Props ={
   appContainer: AppContainer,
-  pageWithMeta : IPageWithMeta<IPageSearchMeta>,
+  pageWithMeta : IPageWithSearchMeta,
   highlightKeywords?: string[],
   showPageControlDropdown?: boolean,
   forceHideMenuItems?: ForceHideMenuItems,
@@ -176,6 +176,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
       ? page.revision
       : page.revision._id;
 
+
     return (
       <div className="d-flex flex-column align-items-end justify-content-center py-md-2">
         <SubNavButtons

+ 7 - 6
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -2,28 +2,30 @@ import React, {
   forwardRef,
   ForwardRefRenderFunction, useCallback, useImperativeHandle, useRef,
 } from 'react';
+
 import { useTranslation } from 'react-i18next';
+
 import { ISelectable, ISelectableAll } from '~/client/interfaces/selectable-all';
 import { toastSuccess } from '~/client/util/apiNotification';
 import {
   IPageInfoForListing, IPageWithMeta, isIPageInfoForListing,
 } from '~/interfaces/page';
-import { IPageSearchMeta } from '~/interfaces/search';
+import { IPageSearchMeta, IPageWithSearchMeta } from '~/interfaces/search';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { useIsGuestUser } from '~/stores/context';
 import { useSWRxPageInfoForList } from '~/stores/page';
 import { usePageTreeTermManager } from '~/stores/page-listing';
 import { useFullTextSearchTermManager } from '~/stores/search';
-import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
+import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import { PageListItemL } from '../PageList/PageListItemL';
 
 
 type Props = {
-  pages: IPageWithMeta<IPageSearchMeta>[],
+  pages: IPageWithSearchMeta[],
   selectedPageId?: string,
   forceHideMenuItems?: ForceHideMenuItems,
-  onPageSelected?: (page?: IPageWithMeta<IPageSearchMeta>) => void,
+  onPageSelected?: (page?: IPageWithSearchMeta) => void,
   onCheckboxChanged?: (isChecked: boolean, pageId: string) => void,
 }
 
@@ -72,7 +74,7 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
     }
   }, [onPageSelected, pages]);
 
-  let injectedPages: (IPageWithMeta<IPageSearchMeta> | IPageWithMeta<IPageInfoForListing & IPageSearchMeta>)[] | undefined;
+  let injectedPages: (IPageWithSearchMeta | IPageWithMeta<IPageInfoForListing & IPageSearchMeta>)[] | undefined;
   // inject data to list
   if (idToPageInfo != null) {
     injectedPages = pages.map((page) => {
@@ -124,7 +126,6 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
     advanceFts();
   };
 
-
   return (
     <ul data-testid="search-result-list" className="page-list-ul list-group list-group-flush">
       { (injectedPages ?? pages).map((page, i) => {

+ 3 - 4
packages/app/src/components/SearchPage2/SearchPageBase.tsx

@@ -7,8 +7,7 @@ import { useTranslation } from 'react-i18next';
 import { ISelectableAll } from '~/client/interfaces/selectable-all';
 import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess } from '~/client/util/apiNotification';
-import { IPageWithMeta } from '~/interfaces/page';
-import { IFormattedSearchResult, IPageSearchMeta } from '~/interfaces/search';
+import { IFormattedSearchResult, IPageWithSearchMeta } from '~/interfaces/search';
 import { OnDeletedFunction } from '~/interfaces/ui';
 import { useIsGuestUser, useIsSearchServiceConfigured, useIsSearchServiceReachable } from '~/stores/context';
 import { usePageDeleteModal } from '~/stores/modal';
@@ -31,7 +30,7 @@ export interface IReturnSelectedPageIds {
 type Props = {
   appContainer: AppContainer,
 
-  pages?: IPageWithMeta<IPageSearchMeta>[],
+  pages?: IPageWithSearchMeta[],
   searchingKeyword?: string,
 
   forceHideMenuItems?: ForceHideMenuItems,
@@ -61,7 +60,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
 
   const [selectedPageIdsByCheckboxes] = useState<Set<string>>(new Set());
   // const [allPageIds] = useState<Set<string>>(new Set());
-  const [selectedPageWithMeta, setSelectedPageWithMeta] = useState<IPageWithMeta<IPageSearchMeta> | undefined>();
+  const [selectedPageWithMeta, setSelectedPageWithMeta] = useState<IPageWithSearchMeta | undefined>();
 
   // publish selectAll()
   useImperativeHandle(ref, () => ({

+ 6 - 7
packages/app/src/components/SearchTypeahead.tsx

@@ -3,14 +3,13 @@ import React, {
   KeyboardEvent, useCallback, useRef, useState, MouseEvent, useEffect,
 } from 'react';
 
+import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
 import { AsyncTypeahead, Menu, MenuItem } from 'react-bootstrap-typeahead';
 
-import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
 
 import { IFocusable } from '~/client/interfaces/focusable';
 import { TypeaheadProps } from '~/client/interfaces/react-bootstrap-typeahead';
-import { IPageSearchMeta } from '~/interfaces/search';
-import { IPageWithMeta } from '~/interfaces/page';
+import { IPageWithSearchMeta } from '~/interfaces/search';
 import { useSWRxSearch } from '~/stores/search';
 
 
@@ -47,7 +46,7 @@ type TypeaheadInstance = {
   clear: () => void,
   focus: () => void,
   toggleMenu: () => void,
-  state: { selected: IPageWithMeta<IPageSearchMeta>[] }
+  state: { selected: IPageWithSearchMeta[] }
 }
 
 const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Props, ref) => {
@@ -130,7 +129,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
   const DELAY_FOR_SUBMISSION = 100;
   const timeoutIdRef = useRef<NodeJS.Timeout>();
 
-  const changeHandler = useCallback((selectedItems: IPageWithMeta<IPageSearchMeta>[]) => {
+  const changeHandler = useCallback((selectedItems: IPageWithSearchMeta[]) => {
     // cancel schedule to submit
     if (timeoutIdRef.current != null) {
       clearTimeout(timeoutIdRef.current);
@@ -163,11 +162,11 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
     }
   }, [onSearchError, searchError]);
 
-  const labelKey = useCallback((option?: IPageWithMeta<IPageSearchMeta>) => {
+  const labelKey = useCallback((option?: IPageWithSearchMeta) => {
     return option?.data.path ?? '';
   }, []);
 
-  const renderMenu = useCallback((options: IPageWithMeta<IPageSearchMeta>[], menuProps) => {
+  const renderMenu = useCallback((options: IPageWithSearchMeta[], menuProps) => {
     if (!isForcused) {
       return <></>;
     }

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

@@ -86,7 +86,7 @@ class ShareLink extends React.Component {
     const { t } = this.props;
 
     return (
-      <div className="container p-0">
+      <div className="container p-0" data-testid="share-link-management">
         <h3 className="grw-modal-head d-flex pb-2">
           { t('share_links.share_link_list') }
           <button className="btn btn-danger ml-auto " type="button" onClick={this.deleteAllLinksButtonHandler}>{t('delete_all')}</button>

+ 9 - 14
packages/app/src/components/Sidebar/CustomSidebar.tsx

@@ -1,13 +1,12 @@
 import React, { FC } from 'react';
 
 import AppContainer from '~/client/services/AppContainer';
-import loggerFactory from '~/utils/logger';
+import { IRevision } from '~/interfaces/revision';
 import { useSWRxPageByPath } from '~/stores/page';
+import { useCustomSidebarRenderer } from '~/stores/renderer';
+import loggerFactory from '~/utils/logger';
 
-import { withUnstatedContainers } from '../UnstatedUtils';
 import RevisionRenderer from '../Page/RevisionRenderer';
-import { IRevision } from '~/interfaces/revision';
-import { useCustomSidebarRenderer } from '~/stores/renderer';
 
 const logger = loggerFactory('growi:cli:CustomSidebar');
 
@@ -26,9 +25,7 @@ type Props = {
   appContainer: AppContainer,
 };
 
-const CustomSidebar: FC<Props> = (props: Props) => {
-
-  const { appContainer } = props;
+const CustomSidebar: FC<Props> = () => {
 
   const { data: renderer } = useCustomSidebarRenderer();
 
@@ -41,6 +38,9 @@ const CustomSidebar: FC<Props> = (props: Props) => {
   const isLoading = page === undefined && error == null;
   const markdown = (page?.revision as IRevision | undefined)?.body;
 
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const RevisionRendererAny: any = RevisionRenderer;
+
   return (
     <>
       <div className="grw-sidebar-content-header p-3 d-flex">
@@ -64,7 +64,7 @@ const CustomSidebar: FC<Props> = (props: Props) => {
       {
         (!isLoading && markdown != null) && (
           <div className="p-3">
-            <RevisionRenderer
+            <RevisionRendererAny
               growiRenderer={renderer}
               markdown={markdown}
               pagePath="/Sidebar"
@@ -83,9 +83,4 @@ const CustomSidebar: FC<Props> = (props: Props) => {
   );
 };
 
-/**
- * Wrapper component for using unstated
- */
-const CustomSidebarWrapper = withUnstatedContainers(CustomSidebar, [AppContainer]);
-
-export default CustomSidebarWrapper;
+export default CustomSidebar;

+ 2 - 2
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -20,7 +20,7 @@ import { IPageForPageDuplicateModal } from '~/stores/modal';
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
-
+import { shouldRecoverPagePaths } from '~/utils/page-operation';
 
 import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
 import CountBadge from '../../Common/CountBadge';
@@ -410,7 +410,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
   // Rename process
   // Icon that draw attention from users for some actions
-  const shouldShowAttentionIcon = !!page.processData?.Rename?.isProcessable;
+  const shouldShowAttentionIcon = page.processData != null ? shouldRecoverPagePaths(page.processData) : false;
 
   return (
     <div

+ 61 - 26
packages/app/src/interfaces/activity.ts

@@ -19,6 +19,8 @@ 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_FOGOT_PASSWORD = 'USER_FOGOT_PASSWORD';
+const ACTION_USER_RESET_PASSWORD = 'USER_RESET_PASSWORD';
 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';
@@ -68,7 +70,7 @@ 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_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';
@@ -106,6 +108,7 @@ const ACTION_ADMIN_MARKDOWN_PRESENTATION_UPDATE = 'ADMIN_MARKDOWN_PRESENTATION_U
 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_SIDEBAR_UPDATE = 'ADMIN_SIDEBAR_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';
@@ -113,6 +116,14 @@ 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_GROWI_DATA_IMPORTED = 'ADMIN_GROWI_DATA_IMPORTED';
+const ACTION_ADMIN_UPLOADED_GROWI_DATA_DISCARDED = 'ADMIN_UPLOADED_GROWI_DATA_DISCARDED';
+const ACTION_ADMIN_ESA_DATA_IMPORTED = 'ADMIN_ESA_DATA_IMPORTED';
+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_IMPORTED = 'ADMIN_QIITA_DATA_IMPORTED';
+const ACTION_ADMIN_QIITA_DATA_UPDATED = 'ADMIN_QIITA_DATA_UPDATED';
+const ACTION_ADMIN_CONNECTION_TEST_OF_QIITA_DATA = 'ADMIN_CONNECTION_TEST_OF_QIITA_DATA';
 const ACTION_ADMIN_ARCHIVE_DATA_CREATE = 'ADMIN_ARCHIVE_DATA_CREATE';
 const ACTION_ADMIN_ARCHIVE_DATA_DOWNLOAD = 'ADMIN_ARCHIVE_DATA_DOWNLOAD';
 const ACTION_ADMIN_ARCHIVE_DATA_DELETE = 'ADMIN_ARCHIVE_DATA_DELETE';
@@ -127,7 +138,7 @@ const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE = 'ADMIN_GLOBAL_NOTIFICAT
 const ACTION_ADMIN_SLACK_WORKSPACE_CREATE = 'ADMIN_SLACK_WORKSPACE_CREATE';
 const ACTION_ADMIN_SLACK_WORKSPACE_DELETE = 'ADMIN_SLACK_WORKSPACE_DELETE';
 const ACTION_ADMIN_SLACK_BOT_TYPE_UPDATE = 'ADMIN_SLACK_BOT_TYPE_UPDATE';
-const ACTION_ADMIN_SLACK_BOT_TYPE_DELETE = 'ADMIN_SLACK_BOT_TYPE_UPDATE';
+const ACTION_ADMIN_SLACK_BOT_TYPE_DELETE = 'ADMIN_SLACK_BOT_TYPE_DELETE';
 const ACTION_ADMIN_SLACK_ACCESS_TOKEN_REGENERATE = 'ADMIN_SLACK_ACCESS_TOKEN_REGENERATE';
 const ACTION_ADMIN_SLACK_MAKE_APP_PRIMARY = 'ADMIN_SLACK_MAKE_APP_PRIMARY';
 const ACTION_ADMIN_SLACK_PERMISSION_UPDATE = 'ADMIN_SLACK_PERMISSION_UPDATE';
@@ -138,20 +149,20 @@ const ACTION_ADMIN_SLACK_WITHOUT_PROXY_PERMISSION_UPDATE = 'ADMIN_SLACK_WITHOUT_
 const ACTION_ADMIN_SLACK_WITHOUT_PROXY_TEST = 'ADMIN_SLACK_WITHOUT_PROXY_TEST';
 const ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE = 'ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE';
 const ACTION_ADMIN_USERS_INVITE = 'ADMIN_USERS_INVITE';
+const ACTION_ADMIN_USERS_PASSWORD_RESET = 'ADMIN_USERS_PASSWORD_RESET';
+const ACTION_ADMIN_USERS_ACTIVATE = 'ADMIN_USERS_ACTIVATE';
+const ACTION_ADMIN_USERS_GIVE_ADMIN = 'ADMIN_USERS_GIVE_ADMIN';
+const ACTION_ADMIN_USERS_REMOVE_ADMIN = 'ADMIN_USERS_REMOVE_ADMIN';
+const ACTION_ADMIN_USERS_DEACTIVATE = 'ADMIN_USERS_DEACTIVATE';
+const ACTION_ADMIN_USERS_SEND_INVITATION_EMAIL = 'ADMIN_USERS_SEND_INVITATION_EMAIL';
+const ACTION_ADMIN_USERS_REMOVE = 'ADMIN_USERS_REMOVE';
 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_CONNECTION = 'ADMIN_SEARCH_CONNECTION';
 const ACTION_ADMIN_SEARCH_INDICES_NORMALIZE = 'ADMIN_SEARCH_INDICES_NORMALIZE';
 const ACTION_ADMIN_SEARCH_INDICES_REBUILD = 'ADMIN_SEARCH_INDICES_REBUILD';
-const ACTION_ADMIN_GROWI_DATA_IMPORTED = 'ADMIN_GROWI_DATA_IMPORTED';
-const ACTION_ADMIN_ESA_DATA_IMPORTED = 'ADMIN_ESA_DATA_IMPORTED';
-const ACTION_ADMIN_QIITA_DATA_IMPORTED = 'ADMIN_QIITA_DATA_IMPORTED';
-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 SupportedTargetModel = {
@@ -187,6 +198,8 @@ export const SupportedAction = {
   ACTION_USER_LOGIN_WITH_BASIC,
   ACTION_USER_LOGIN_FAILURE,
   ACTION_USER_LOGOUT,
+  ACTION_USER_FOGOT_PASSWORD,
+  ACTION_USER_RESET_PASSWORD,
   ACTION_USER_PERSONAL_SETTINGS_UPDATE,
   ACTION_USER_IMAGE_TYPE_UPDATE,
   ACTION_USER_LDAP_ACCOUNT_ASSOCIATE,
@@ -274,6 +287,7 @@ export const SupportedAction = {
   ACTION_ADMIN_MARKDOWN_XSS_UPDATE,
   ACTION_ADMIN_LAYOUT_UPDATE,
   ACTION_ADMIN_THEME_UPDATE,
+  ACTION_ADMIN_SIDEBAR_UPDATE,
   ACTION_ADMIN_FUNCTION_UPDATE,
   ACTION_ADMIN_CODE_HIGHLIGHT_UPDATE,
   ACTION_ADMIN_CUSTOM_TITLE_UPDATE,
@@ -281,6 +295,14 @@ export const SupportedAction = {
   ACTION_ADMIN_CUSTOM_CSS_UPDATE,
   ACTION_ADMIN_CUSTOM_SCRIPT_UPDATE,
   ACTION_ADMIN_ARCHIVE_DATA_UPLOAD,
+  ACTION_ADMIN_GROWI_DATA_IMPORTED,
+  ACTION_ADMIN_ESA_DATA_IMPORTED,
+  ACTION_ADMIN_QIITA_DATA_IMPORTED,
+  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,
   ACTION_ADMIN_ARCHIVE_DATA_CREATE,
   ACTION_ADMIN_ARCHIVE_DATA_DOWNLOAD,
   ACTION_ADMIN_ARCHIVE_DATA_DELETE,
@@ -306,20 +328,20 @@ export const SupportedAction = {
   ACTION_ADMIN_SLACK_WITHOUT_PROXY_TEST,
   ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE,
   ACTION_ADMIN_USERS_INVITE,
+  ACTION_ADMIN_USERS_PASSWORD_RESET,
+  ACTION_ADMIN_USERS_ACTIVATE,
+  ACTION_ADMIN_USERS_DEACTIVATE,
+  ACTION_ADMIN_USERS_GIVE_ADMIN,
+  ACTION_ADMIN_USERS_REMOVE_ADMIN,
+  ACTION_ADMIN_USERS_SEND_INVITATION_EMAIL,
+  ACTION_ADMIN_USERS_REMOVE,
   ACTION_ADMIN_USER_GROUP_CREATE,
   ACTION_ADMIN_USER_GROUP_UPDATE,
   ACTION_ADMIN_USER_GROUP_DELETE,
   ACTION_ADMIN_USER_GROUP_ADD_USER,
+  ACTION_ADMIN_SEARCH_CONNECTION,
   ACTION_ADMIN_SEARCH_INDICES_NORMALIZE,
   ACTION_ADMIN_SEARCH_INDICES_REBUILD,
-  ACTION_ADMIN_GROWI_DATA_IMPORTED,
-  ACTION_ADMIN_ESA_DATA_IMPORTED,
-  ACTION_ADMIN_QIITA_DATA_IMPORTED,
-  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;
 
 // Action required for notification
@@ -354,12 +376,16 @@ export const SmallActionGroup = {
   ACTION_USER_LOGOUT,
   ACTION_PAGE_CREATE,
   ACTION_PAGE_DELETE,
+  ACTION_PAGE_DELETE_COMPLETELY,
+  ACTION_PAGE_EMPTY_TRASH,
 } as const;
 
 // SmallActionGroup + Action by all General Users - PAGE_VIEW
 export const MediumActionGroup = {
   ...SmallActionGroup,
   ACTION_USER_REGISTRATION_SUCCESS,
+  ACTION_USER_FOGOT_PASSWORD,
+  ACTION_USER_RESET_PASSWORD,
   ACTION_USER_PERSONAL_SETTINGS_UPDATE,
   ACTION_USER_IMAGE_TYPE_UPDATE,
   ACTION_USER_LDAP_ACCOUNT_ASSOCIATE,
@@ -444,6 +470,7 @@ export const LargeActionGroup = {
   ACTION_ADMIN_MARKDOWN_XSS_UPDATE,
   ACTION_ADMIN_LAYOUT_UPDATE,
   ACTION_ADMIN_THEME_UPDATE,
+  ACTION_ADMIN_SIDEBAR_UPDATE,
   ACTION_ADMIN_FUNCTION_UPDATE,
   ACTION_ADMIN_CODE_HIGHLIGHT_UPDATE,
   ACTION_ADMIN_CUSTOM_TITLE_UPDATE,
@@ -451,6 +478,14 @@ export const LargeActionGroup = {
   ACTION_ADMIN_CUSTOM_CSS_UPDATE,
   ACTION_ADMIN_CUSTOM_SCRIPT_UPDATE,
   ACTION_ADMIN_ARCHIVE_DATA_UPLOAD,
+  ACTION_ADMIN_GROWI_DATA_IMPORTED,
+  ACTION_ADMIN_ESA_DATA_IMPORTED,
+  ACTION_ADMIN_QIITA_DATA_IMPORTED,
+  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,
   ACTION_ADMIN_ARCHIVE_DATA_CREATE,
   ACTION_ADMIN_ARCHIVE_DATA_DOWNLOAD,
   ACTION_ADMIN_ARCHIVE_DATA_DELETE,
@@ -476,20 +511,20 @@ export const LargeActionGroup = {
   ACTION_ADMIN_SLACK_WITHOUT_PROXY_TEST,
   ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE,
   ACTION_ADMIN_USERS_INVITE,
+  ACTION_ADMIN_USERS_PASSWORD_RESET,
+  ACTION_ADMIN_USERS_ACTIVATE,
+  ACTION_ADMIN_USERS_DEACTIVATE,
+  ACTION_ADMIN_USERS_GIVE_ADMIN,
+  ACTION_ADMIN_USERS_REMOVE_ADMIN,
+  ACTION_ADMIN_USERS_SEND_INVITATION_EMAIL,
+  ACTION_ADMIN_USERS_REMOVE,
   ACTION_ADMIN_USER_GROUP_CREATE,
   ACTION_ADMIN_USER_GROUP_UPDATE,
   ACTION_ADMIN_USER_GROUP_DELETE,
   ACTION_ADMIN_USER_GROUP_ADD_USER,
+  ACTION_ADMIN_SEARCH_CONNECTION,
   ACTION_ADMIN_SEARCH_INDICES_NORMALIZE,
   ACTION_ADMIN_SEARCH_INDICES_REBUILD,
-  ACTION_ADMIN_GROWI_DATA_IMPORTED,
-  ACTION_ADMIN_ESA_DATA_IMPORTED,
-  ACTION_ADMIN_QIITA_DATA_IMPORTED,
-  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;
 
 

+ 17 - 0
packages/app/src/interfaces/editor-methods.ts

@@ -0,0 +1,17 @@
+export interface IEditorMethods {
+  forceToFocus: () => void,
+  setValue: (newValue: string) => void,
+  setGfmMode: (bool: boolean) => void,
+  setCaretLine: (line: number) => void,
+  setScrollTopByLine: (line: number) => void,
+  getStrFromBol(): void,
+  getStrToEol: () => void,
+  getStrFromBolToSelectedUpperPos: () => void,
+  replaceBolToCurrentPos: (text: string) => void,
+  replaceLine: (text: string) => void,
+  insertText: (text: string) => void,
+  insertLinebreak: () => void,
+  dispatchSave: () => void,
+  dispatchPasteFiles: (event: Event) => void,
+  getNavbarItems: () => JSX.Element[],
+}

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

@@ -8,7 +8,6 @@ import { IInterceptorManager } from './interceptor-manager';
 
 export type CustomWindow = Window
                          & typeof globalThis
-                         & { xss: Xss }
                          & { interceptorManager: IInterceptorManager }
                          & { globalEmitter: EventEmitter }
                          & { GraphViewer: IGraphViewer }

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

@@ -1,3 +1,11 @@
 export interface IGraphViewer {
   createViewerForElement: (Element) => void,
 }
+
+export const isGraphViewer = (val: any): val is IGraphViewer => {
+  if (typeof val === 'function' && typeof val.createViewerForElement === 'function') {
+    return true;
+  }
+
+  return false;
+};

+ 15 - 4
packages/app/src/interfaces/page-operation.ts

@@ -6,10 +6,21 @@ export const PageActionType = {
   Revert: 'Revert',
   NormalizeParent: 'NormalizeParent',
 } as const;
-export type PageActionType = typeof PageActionType[keyof typeof PageActionType]
-export type IPageOperationProcessData = Partial<{
-  [key in PageActionType]: {isProcessable: boolean}
-}>
+export type PageActionType = typeof PageActionType[keyof typeof PageActionType];
+
+export const PageActionStage = {
+  Main: 'Main',
+  Sub: 'Sub',
+} as const;
+export type PageActionStage = typeof PageActionStage[keyof typeof PageActionStage];
+
+export type IPageOperationProcessData = {
+  [key in PageActionType]?: {
+    [PageActionStage.Main]?: { isProcessable: boolean },
+    [PageActionStage.Sub]?: { isProcessable: boolean },
+  }
+}
+
 export type IPageOperationProcessInfo = {
   [pageId: string]: IPageOperationProcessData,
 }

+ 8 - 6
packages/app/src/interfaces/page.ts

@@ -29,6 +29,7 @@ export interface IPage {
   pageIdOnHackmd: string,
   revisionHackmdSynced: Ref<IRevision>,
   hasDraftOnHackmd: boolean,
+  expandContentWidth?: boolean,
   deleteUser: Ref<IUser>,
   deletedAt: Date,
 }
@@ -56,11 +57,12 @@ export type IPageInfo = {
 }
 
 export type IPageInfoForEntity = IPageInfo & {
-  bookmarkCount?: number,
-  sumOfLikers?: number,
-  likerIds?: string[],
-  sumOfSeenUsers?: number,
-  seenUserIds?: string[],
+  bookmarkCount: number,
+  sumOfLikers: number,
+  likerIds: string[],
+  sumOfSeenUsers: number,
+  seenUserIds: string[],
+  expandContentWidth?: boolean,
 }
 
 export type IPageInfoForOperation = IPageInfoForEntity & {
@@ -113,7 +115,7 @@ export type IDataWithMeta<D = unknown, M = unknown> = {
   meta?: M,
 }
 
-export type IPageWithMeta<M = IPageInfoAll> = IDataWithMeta<IPageHasId, M>;
+export type IPageWithMeta<M extends IPageInfo = IPageInfo> = IDataWithMeta<IPageHasId, M>;
 
 export type IPageToDeleteWithMeta<T = IPageInfoForEntity | unknown> = IDataWithMeta<HasObjectId & (IPage | { path: string, revision: string | null}), T>;
 export type IPageToRenameWithMeta<T = IPageInfoForEntity | unknown> = IPageToDeleteWithMeta<T>;

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

@@ -1,4 +1,4 @@
-import { IPageWithMeta } from './page';
+import { IDataWithMeta, IPageHasId } from './page';
 
 export type IPageSearchMeta = {
   bookmarkCount?: number,
@@ -14,10 +14,6 @@ export const isIPageSearchMeta = (meta: any): meta is IPageSearchMeta => {
   return meta != null && 'elasticSearchResult' in meta;
 };
 
-export type ISearchResult<T > = ISearchResultMeta & {
-  data: T[],
-}
-
 export type ISearchResultMeta = {
   meta: {
     took?: number
@@ -26,7 +22,15 @@ export type ISearchResultMeta = {
   },
 }
 
-export type IFormattedSearchResult = ISearchResult<IPageWithMeta<IPageSearchMeta>>;
+export type ISearchResult<T> = ISearchResultMeta & {
+  data: T[],
+}
+
+export type IPageWithSearchMeta = IDataWithMeta<IPageHasId, IPageSearchMeta>;
+
+export type IFormattedSearchResult = ISearchResultMeta & {
+  data: IPageWithSearchMeta[],
+}
 
 export const SORT_AXIS = {
   RELATION_SCORE: 'relationScore',

+ 2 - 1
packages/app/src/linter-checker/test.scss

@@ -1,7 +1,8 @@
 /*
  * VSCode の Stylelint 設定チェック方法
  *
- * 1. .stylelintrc.json ファイル中の `src/linter-checker/test.scss` 行を削除
+ * 1. .stylelintrc.json ファイル中の `src/linter-checker/test.scss` 行を削除し、
+ *    このファイルを VSCode 上で開き直す
  *
  * 2. VSCode で以下のエラーが表示されていることを確認
  *   - color で stylelint(order/properties-order)

+ 7 - 6
packages/app/src/migrations/20211129125654-initialize-private-legacy-pages-named-query.js

@@ -1,8 +1,8 @@
+import { getMongoUri, mongoOptions } from '@growi/core';
 import mongoose from 'mongoose';
 
-import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
-import NamedQuery from '~/server/models/named-query';
 import { SearchDelegatorName } from '~/interfaces/named-query';
+import NamedQuery from '~/server/models/named-query';
 import loggerFactory from '~/utils/logger';
 
 
@@ -13,10 +13,11 @@ module.exports = {
     mongoose.connect(getMongoUri(), mongoOptions);
 
     try {
-      await NamedQuery.insertMany({
-        name: SearchDelegatorName.PRIVATE_LEGACY_PAGES,
-        delegatorName: SearchDelegatorName.PRIVATE_LEGACY_PAGES,
-      });
+      await NamedQuery.updateOne(
+        { name: SearchDelegatorName.PRIVATE_LEGACY_PAGES },
+        { delegatorName: SearchDelegatorName.PRIVATE_LEGACY_PAGES },
+        { upsert: true },
+      );
     }
     catch (err) {
       logger.error('Failed to migrate named query for private legacy pages search delagator.', err);

+ 44 - 0
packages/app/src/migrations/20220613064207-add-attachment-type-to-existing-attachments.js

@@ -0,0 +1,44 @@
+import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
+import mongoose from 'mongoose';
+
+import { AttachmentType } from '~/server/interfaces/attachment';
+import attachmentModel from '~/server/models/attachment';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:migrate:add-attachment-type-to-existing-attachments');
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(getMongoUri(), mongoOptions);
+    const Attachment = getModelSafely('Attachment') || attachmentModel();
+
+    // Add attachmentType for wiki page
+    // Filter pages where "attachmentType" doesn't exist and "page" is not null
+    const operationsForWikiPage = {
+      updateMany:
+         {
+           filter: { page: { $ne: null }, attachmentType: { $exists: false } },
+           update: { $set: { attachmentType: AttachmentType.WIKI_PAGE } },
+         },
+    };
+
+    // Add attachmentType for profile image
+    // Filter pages where "attachmentType" doesn't exist and "page" is null
+    const operationsForProfileImage = {
+      updateMany:
+        {
+          filter: { page: { $eq: null }, attachmentType: { $exists: false } },
+          update: { $set: { attachmentType: AttachmentType.PROFILE_IMAGE } },
+        },
+    };
+    await Attachment.bulkWrite([operationsForWikiPage, operationsForProfileImage]);
+
+    logger.info('Migration has successfully applied');
+
+  },
+
+  async down(db) {
+    // No rollback
+  },
+};

+ 27 - 0
packages/app/src/server/crowi/express-init.js

@@ -1,6 +1,9 @@
 import mongoose from 'mongoose';
 
 import { i18n, localePath } from '~/next-i18next.config';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:crowi:express-init');
 
 module.exports = function(crowi, app) {
   const debug = require('debug')('growi:crowi:express-init');
@@ -57,6 +60,30 @@ module.exports = function(crowi, app) {
 
   app.use(compression());
 
+
+  const { configManager } = crowi;
+
+  const trustProxyBool = configManager.getConfig('crowi', 'security:trustProxyBool');
+  const trustProxyCsv = configManager.getConfig('crowi', 'security:trustProxyCsv');
+  const trustProxyHops = configManager.getConfig('crowi', 'security:trustProxyHops');
+
+  const trustProxy = trustProxyBool ?? trustProxyCsv ?? trustProxyHops;
+
+  try {
+    if (trustProxy != null) {
+      const isNotSpec = [trustProxyBool, trustProxyCsv, trustProxyHops].filter(trustProxy => trustProxy != null).length !== 1;
+      if (isNotSpec) {
+        // eslint-disable-next-line max-len
+        logger.warn(`If more than one TRUST_PROXY_ ~ environment variable is set, the values are set in the following order of inequality size (BOOL > CSV > HOPS) first. Set value: ${trustProxy}`);
+      }
+      app.set('trust proxy', trustProxy);
+    }
+  }
+  catch (err) {
+    logger.error(err);
+  }
+
+
   app.use(helmet({
     contentSecurityPolicy: false,
     expectCt: false,

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

@@ -10,13 +10,14 @@ import mongoose from 'mongoose';
 
 import pkg from '^/package.json';
 
+import { PageActionType } from '~/interfaces/page-operation';
 import CdnResourcesService from '~/services/cdn-resources-service';
 import Xss from '~/services/xss';
 import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
 
 import Activity from '../models/activity';
-import PageOperation, { PageActionType } from '../models/page-operation';
+import PageOperation from '../models/page-operation';
 import PageRedirect from '../models/page-redirect';
 import Tag from '../models/tag';
 import UserGroup from '../models/user-group';

+ 7 - 0
packages/app/src/server/interfaces/attachment.ts

@@ -0,0 +1,7 @@
+export const AttachmentType = {
+  BRAND_LOGO: 'BRAND_LOGO',
+  WIKI_PAGE: 'WIKI_PAGE',
+  PROFILE_IMAGE: 'PROFILE_IMAGE',
+} as const;
+
+export type AttachmentType = typeof AttachmentType[keyof typeof AttachmentType];

+ 0 - 12
packages/app/src/server/models/activity.ts

@@ -126,18 +126,6 @@ activitySchema.statics.updateByParameters = async function(activityId: string, p
   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}> {

+ 9 - 2
packages/app/src/server/models/attachment.js

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
+import { AttachmentType } from '../interfaces/attachment';
+
 // disable no-return-await for model functions
 /* eslint-disable no-return-await */
 
@@ -34,6 +36,11 @@ module.exports = function(crowi) {
     fileSize: { type: Number, default: 0 },
     temporaryUrlCached: { type: String },
     temporaryUrlExpiredAt: { type: Date },
+    attachmentType: {
+      type: String,
+      enum: AttachmentType,
+      required: true,
+    },
   }, {
     timestamps: { createdAt: true, updatedAt: false },
   });
@@ -52,7 +59,7 @@ module.exports = function(crowi) {
   attachmentSchema.set('toJSON', { virtuals: true });
 
 
-  attachmentSchema.statics.createWithoutSave = function(pageId, user, fileStream, originalName, fileFormat, fileSize) {
+  attachmentSchema.statics.createWithoutSave = function(pageId, user, fileStream, originalName, fileFormat, fileSize, attachmentType) {
     const Attachment = this;
 
     const extname = path.extname(originalName);
@@ -68,7 +75,7 @@ module.exports = function(crowi) {
     attachment.fileName = fileName;
     attachment.fileFormat = fileFormat;
     attachment.fileSize = fileSize;
-
+    attachment.attachmentType = attachmentType;
     return attachment;
   };
 

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

@@ -191,6 +191,8 @@ export const defaultNotificationConfigs: { [key: string]: any } = {
 schema.statics.getLocalconfig = function(crowi) {
   const env = process.env;
 
+  const isDefaultLogo = crowi.configManager.getConfig('crowi', 'customize:isDefaultLogo');
+
   const localConfig = {
     crowi: {
       title: crowi.appService.getAppTitle(),
@@ -245,6 +247,9 @@ schema.statics.getLocalconfig = function(crowi) {
     globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
     pageLimitationL: crowi.configManager.getConfig('crowi', 'customize:showPageLimitationL'),
     pageLimitationXL: crowi.configManager.getConfig('crowi', 'customize:showPageLimitationXL'),
+    customizedLogoSrc: isDefaultLogo != null && !isDefaultLogo
+      ? crowi.configManager.getConfig('crowi', 'customize:customizedLogoSrc')
+      : null,
     auditLogEnabled: crowi.configManager.getConfig('crowi', 'app:auditLogEnabled'),
     activityExpirationSeconds: crowi.configManager.getConfig('crowi', 'app:activityExpirationSeconds'),
     auditLogAvailableActions: crowi.activityService.getAvailableActions(false),

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

@@ -683,6 +683,7 @@ export const getPageSchema = (crowi) => {
     const Revision = crowi.model('Revision');
     const format = options.format || 'markdown';
     const grantUserGroupId = options.grantUserGroupId || null;
+    const expandContentWidth = crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
 
     // sanitize path
     path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
@@ -704,7 +705,9 @@ export const getPageSchema = (crowi) => {
     page.creator = user;
     page.lastUpdateUser = user;
     page.status = STATUS_PUBLISHED;
-
+    if (expandContentWidth != null) {
+      page.expandContentWidth = expandContentWidth;
+    }
     await validateAppliedScope(user, grant, grantUserGroupId);
     page.applyScope(user, grant, grantUserGroupId);
 

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

@@ -4,6 +4,7 @@ import mongoose, {
   Schema, Model, Document, QueryOptions, FilterQuery,
 } from 'mongoose';
 
+import { PageActionType, PageActionStage } from '~/interfaces/page-operation';
 import {
   IPageForResuming, IUserForResuming, IOptionsForResuming,
 } from '~/server/models/interfaces/page-operation';
@@ -18,22 +19,6 @@ const logger = loggerFactory('growi:models:page-operation');
 type IObjectId = mongoose.Types.ObjectId;
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
-export const PageActionType = {
-  Rename: 'Rename',
-  Duplicate: 'Duplicate',
-  Delete: 'Delete',
-  DeleteCompletely: 'DeleteCompletely',
-  Revert: 'Revert',
-  NormalizeParent: 'NormalizeParent',
-} as const;
-export type PageActionType = typeof PageActionType[keyof typeof PageActionType];
-
-export const PageActionStage = {
-  Main: 'Main',
-  Sub: 'Sub',
-} as const;
-export type PageActionStage = typeof PageActionStage[keyof typeof PageActionStage];
-
 /*
  * Main Schema
  */

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

@@ -102,6 +102,7 @@ const schema = new Schema<PageDocument, PageModel>({
   pageIdOnHackmd: { type: String },
   revisionHackmdSynced: { type: ObjectId, ref: 'Revision' }, // the revision that is synced to HackMD
   hasDraftOnHackmd: { type: Boolean }, // set true if revision and revisionHackmdSynced are same but HackMD document has modified
+  expandContentWidth: { type: Boolean },
   updatedAt: { type: Date, default: Date.now }, // Do not use timetamps for updatedAt because it breaks 'updateMetadata: false' option
   deleteUser: { type: ObjectId, ref: 'User' },
   deletedAt: { type: Date },

+ 6 - 5
packages/app/src/server/models/password-reset-order.ts

@@ -1,11 +1,12 @@
+import crypto from 'crypto';
+
+import { getOrCreateModel } from '@growi/core';
+import { addMinutes } from 'date-fns';
 import mongoose, {
   Schema, Model, Document,
 } from 'mongoose';
-
-import { addMinutes } from 'date-fns';
 import uniqueValidator from 'mongoose-unique-validator';
-import crypto from 'crypto';
-import { getOrCreateModel } from '@growi/core';
+
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
@@ -20,7 +21,7 @@ export interface IPasswordResetOrder {
 }
 
 export interface PasswordResetOrderDocument extends IPasswordResetOrder, Document {
-  isExpired(): Promise<boolean>
+  isExpired(): boolean
   revokeOneTimeToken(): Promise<void>
 }
 

+ 1 - 1
packages/app/src/server/models/user-registration-order.ts

@@ -17,7 +17,7 @@ export interface IUserRegistrationOrder {
 }
 
 export interface UserRegistrationOrderDocument extends IUserRegistrationOrder, Document {
-  isExpired(): Promise<boolean>
+  isExpired(): boolean
   revokeOneTimeToken(): Promise<void>
 }
 

+ 26 - 3
packages/app/src/server/routes/apiv3/activity.ts

@@ -2,12 +2,13 @@ import { parseISO, addMinutes, isValid } from 'date-fns';
 import express, { Request, Router } from 'express';
 import { query } from 'express-validator';
 
-import { ISearchFilter } from '~/interfaces/activity';
+import { IActivity, 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 { serializeUserSecurely } from '../../models/serializers/user-serializer';
 
 import { ApiV3Response } from './interfaces/apiv3-response';
 
@@ -92,8 +93,30 @@ module.exports = (crowi: Crowi): Router => {
     }
 
     try {
-      const paginationResult = await Activity.getPaginatedActivity(limit, offset, query);
-      return res.apiv3({ paginationResult });
+      const paginateResult = await Activity.paginate(
+        query,
+        {
+          limit,
+          offset,
+          sort: { createdAt: -1 },
+          populate: 'user',
+        },
+      );
+
+      const User = crowi.model('User');
+      const serializedDocs = paginateResult.docs.map((doc: IActivity) => {
+        if (doc.user != null && doc.user instanceof User) {
+          doc.user = serializeUserSecurely(doc.user);
+        }
+        return doc;
+      });
+
+      const serializedPaginationResult = {
+        ...paginateResult,
+        docs: serializedDocs,
+      };
+
+      return res.apiv3({ serializedPaginationResult });
     }
     catch (err) {
       logger.error('Failed to get paginated activity', err);

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

@@ -1,5 +1,7 @@
 /* eslint-disable no-unused-vars */
+
 import { SupportedAction } from '~/interfaces/activity';
+import { AttachmentType } from '~/server/interfaces/attachment';
 import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
@@ -13,9 +15,11 @@ const express = require('express');
 const router = express.Router();
 
 const { body, query } = require('express-validator');
+const multer = require('multer');
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
+
 /**
  * @swagger
  *  tags:
@@ -100,8 +104,9 @@ module.exports = (crowi) => {
 
   const activityEvent = crowi.event('activity');
 
-  const { customizeService } = crowi;
-
+  const { customizeService, attachmentService } = crowi;
+  const Attachment = crowi.model('Attachment');
+  const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
   const validator = {
     layout: [
       body('isContainerFluid').isBoolean(),
@@ -146,6 +151,10 @@ module.exports = (crowi) => {
     customizeScript: [
       body('customizeScript').isString(),
     ],
+    logo: [
+      body('isDefaultLogo').isBoolean().optional({ nullable: true }),
+      body('customizedLogoSrc').isString().optional({ nullable: true }),
+    ],
   };
 
   /**
@@ -169,7 +178,6 @@ module.exports = (crowi) => {
    *                      description: customize params
    */
   router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
-
     const customizeParams = {
       themeType: await crowi.configManager.getConfig('crowi', 'customize:theme'),
       isEnabledTimeline: await crowi.configManager.getConfig('crowi', 'customize:isEnabledTimeline'),
@@ -365,7 +373,7 @@ module.exports = (crowi) => {
     }
   });
 
-  router.put('/sidebar', loginRequiredStrictly, adminRequired, csrf, validator.sidebar, apiV3FormValidator, async(req, res) => {
+  router.put('/sidebar', loginRequiredStrictly, adminRequired, csrf, validator.sidebar, apiV3FormValidator, addActivity, async(req, res) => {
     const requestParams = {
       'customize:isSidebarDrawerMode': req.body.isSidebarDrawerMode,
       'customize:isSidebarClosedAtDockMode': req.body.isSidebarClosedAtDockMode,
@@ -377,6 +385,9 @@ module.exports = (crowi) => {
         isSidebarDrawerMode: await crowi.configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
         isSidebarClosedAtDockMode: await crowi.configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
       };
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SIDEBAR_UPDATE });
+
       return res.apiv3({ customizedParams });
     }
     catch (err) {
@@ -673,5 +684,100 @@ module.exports = (crowi) => {
     }
   });
 
+  router.get('/customize-logo', loginRequiredStrictly, adminRequired, async(req, res) => {
+    const isDefaultLogo = await crowi.configManager.getConfig('crowi', 'customize:isDefaultLogo');
+    const customizedLogoSrc = await crowi.configManager.getConfig('crowi', 'customize:customizedLogoSrc');
+    return res.apiv3({ isDefaultLogo, customizedLogoSrc });
+  });
+
+  router.put('/customize-logo', csrf, loginRequiredStrictly, adminRequired, validator.logo, apiV3FormValidator, async(req, res) => {
+
+    const {
+      isDefaultLogo, customizedLogoSrc,
+    } = req.body;
+
+    const requestParams = {
+      'customize:isDefaultLogo': isDefaultLogo,
+      'customize:customizedLogoSrc': customizedLogoSrc,
+    };
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
+      const customizedParams = {
+        isDefaultLogo: await crowi.configManager.getConfig('crowi', 'customize:isDefaultLogo'),
+        customizedLogoSrc: await crowi.configManager.getConfig('crowi', 'customize:customizedLogoSrc'),
+      };
+      return res.apiv3({ customizedParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating customizeLogo';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-customizeLogo-failed'));
+    }
+  });
+
+  router.post('/upload-brand-logo', uploads.single('file'), loginRequiredStrictly,
+    adminRequired, csrf, validator.logo, apiV3FormValidator, async(req, res) => {
+
+      if (req.file == null) {
+        return res.apiv3Err(new ErrorV3('File error.', 'upload-brand-logo-failed'));
+      }
+      if (req.user == null) {
+        return res.apiv3Err(new ErrorV3('param "user" must be set.', 'upload-brand-logo-failed'));
+      }
+
+      const file = req.file;
+
+      // check type
+      const acceptableFileType = /image\/.+/;
+      if (!file.mimetype.match(acceptableFileType)) {
+        const msg = 'File type error. Only image files is allowed to set as user picture.';
+        return res.apiv3Err(new ErrorV3(msg, 'upload-brand-logo-failed'));
+      }
+
+      // Check if previous attachment exists and remove it
+      const attachments = await Attachment.find({ attachmentType: AttachmentType.BRAND_LOGO });
+      if (attachments != null) {
+        await attachmentService.removeAllAttachments(attachments);
+      }
+
+      let attachment;
+      try {
+        attachment = await attachmentService.createAttachment(file, req.user, null, AttachmentType.BRAND_LOGO);
+        const attachmentConfigParams = {
+          'customize:customizedLogoSrc': attachment.filePathProxied,
+        };
+
+        await crowi.configManager.updateConfigsInTheSameNamespace('crowi', attachmentConfigParams);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3(err.message, 'upload-brand-logo-failed'));
+      }
+      attachment.toObject({ virtuals: true });
+      return res.apiv3({ attachment });
+    });
+
+  router.delete('/delete-brand-logo', csrf, loginRequiredStrictly, adminRequired, async(req, res) => {
+
+    const attachments = await Attachment.find({ attachmentType: AttachmentType.BRAND_LOGO });
+
+    if (attachments == null) {
+      return res.apiv3Err(new ErrorV3('attachment not found', 'delete-brand-logo-failed'));
+    }
+
+    try {
+      await attachmentService.removeAllAttachments(attachments);
+      // update attachmentId immediately
+      const attachmentConfigParams = { 'customize:customizedLogoSrc': null };
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', attachmentConfigParams);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.status(500).apiv3Err(new ErrorV3('Error while deleting logo', 'delete-brand-logo-failed'));
+    }
+
+    return res.apiv3({});
+  });
+
   return router;
 };

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

@@ -1,5 +1,7 @@
 import { format, subSeconds } from 'date-fns';
 
+import { SupportedAction } from '~/interfaces/activity';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import injectResetOrderByTokenMiddleware from '~/server/middlewares/inject-reset-order-by-token-middleware';
 import PasswordResetOrder from '~/server/models/password-reset-order';
 import ErrorV3 from '~/server/models/vo/error-apiv3';
@@ -24,6 +26,9 @@ module.exports = (crowi) => {
   const User = crowi.model('User');
   const path = require('path');
   const csrf = require('../../middlewares/csrf')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const activityEvent = crowi.event('activity');
 
   const minPasswordLength = crowi.configManager.getConfig('crowi', 'app:minPasswordLength');
 
@@ -56,7 +61,7 @@ module.exports = (crowi) => {
     });
   }
 
-  router.post('/', checkPassportStrategyMiddleware, async(req, res) => {
+  router.post('/', checkPassportStrategyMiddleware, addActivity, async(req, res) => {
     const { email } = req.body;
     const i18n = configManager.getConfig('crowi', 'app:globalLang');
     const appUrl = appService.getSiteUrl();
@@ -77,6 +82,9 @@ module.exports = (crowi) => {
       const expiredAt = subSeconds(passwordResetOrderData.expiredAt, grwTzoffsetSec);
       const formattedExpiredAt = format(expiredAt, 'yyyy/MM/dd HH:mm');
       await sendPasswordResetEmail('passwordReset', i18n, email, oneTimeUrl, formattedExpiredAt);
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_USER_FOGOT_PASSWORD });
+
       return res.apiv3();
     }
     catch (err) {
@@ -87,7 +95,7 @@ module.exports = (crowi) => {
   });
 
   // eslint-disable-next-line max-len
-  router.put('/', checkPassportStrategyMiddleware, injectResetOrderByTokenMiddleware, csrf, validator.password, apiV3FormValidator, async(req, res) => {
+  router.put('/', checkPassportStrategyMiddleware, injectResetOrderByTokenMiddleware, csrf, validator.password, apiV3FormValidator, addActivity, async(req, res) => {
     const { passwordResetOrder } = req;
     const { email } = passwordResetOrder;
     const grobalLang = configManager.getConfig('crowi', 'app:globalLang');
@@ -106,6 +114,9 @@ module.exports = (crowi) => {
       const serializedUserData = serializeUserSecurely(userData);
       passwordResetOrder.revokeOneTimeToken();
       await sendPasswordResetEmail('passwordResetSuccessful', i18n, email);
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_USER_RESET_PASSWORD });
+
       return res.apiv3({ userData: serializedUserData });
     }
     catch (err) {

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

@@ -2,7 +2,7 @@ import express, { Request, Router } from 'express';
 import { query, oneOf } from 'express-validator';
 import mongoose from 'mongoose';
 
-import { IPageInfoAll, isIPageInfoForEntity, IPageInfoForListing } from '~/interfaces/page';
+import { isIPageInfoForEntity, IPageInfoForListing, IPageInfo } from '~/interfaces/page';
 import { IUserHasId } from '~/interfaces/user';
 import loggerFactory from '~/utils/logger';
 
@@ -127,7 +127,7 @@ export default (crowi: Crowi): Router => {
         bookmarkCountMap = await Bookmark.getPageIdToCountMap(foundIds) as Record<string, number>;
       }
 
-      const idToPageInfoMap: Record<string, IPageInfoAll> = {};
+      const idToPageInfoMap: Record<string, IPageInfo | IPageInfoForListing> = {};
 
       const isGuestUser = req.user == null;
       for (const page of pages) {

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

@@ -18,7 +18,6 @@ const router = express.Router();
 const { convertToNewAffiliationPath, isTopPage } = pagePathUtils;
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
-
 /**
  * @swagger
  *  tags:
@@ -166,8 +165,9 @@ module.exports = (crowi) => {
   const certifySharedPage = require('../../middlewares/certify-shared-page')(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
+  const configManager = crowi.configManager;
+
   const globalNotificationService = crowi.getGlobalNotificationService();
-  const socketIoService = crowi.socketIoService;
   const { Page, GlobalNotificationSetting, Bookmark } = crowi.models;
   const { pageService, exportService } = crowi;
 
@@ -220,6 +220,9 @@ module.exports = (crowi) => {
     subscribeStatus: [
       query('pageId').isString(),
     ],
+    contentWidth: [
+      body('expandContentWidth').isBoolean(),
+    ],
   };
 
   /**
@@ -817,5 +820,27 @@ module.exports = (crowi) => {
     }
   });
 
+
+  router.put('/:pageId/content-width', accessTokenParser, loginRequiredStrictly, csrf,
+    validator.contentWidth, apiV3FormValidator, async(req, res) => {
+      const { pageId } = req.params;
+      const { expandContentWidth } = req.body;
+
+      const isContainerFluidBySystem = configManager.getConfig('crowi', 'customize:isContainerFluid');
+
+      try {
+        const updateQuery = expandContentWidth === isContainerFluidBySystem
+          ? { $unset: { expandContentWidth } } // remove if the specified value is the same to the system's one
+          : { $set: { expandContentWidth } };
+
+        const page = await Page.updateOne({ _id: pageId }, updateQuery);
+        return res.apiv3({ page });
+      }
+      catch (err) {
+        logger.error('update-content-width-failed', err);
+        return res.apiv3Err(err, 500);
+      }
+    });
+
   return router;
 };

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

@@ -575,8 +575,14 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(msg, code), 403);
     }
 
+    const pageOp = await crowi.pageOperationService.getRenameSubOperationByPageId(page._id);
+    if (pageOp == null) {
+      const msg = 'PageOperation document for Rename Sub operation not found.';
+      const code = 'document_not_found';
+      return res.apiv3Err(new ErrorV3(msg, code), 404);
+    }
+
     try {
-      const pageOp = await crowi.pageOperationService.getRenameSubOperationByPageId(page._id);
       await crowi.pageService.resumeRenameSubOperation(page, pageOp);
     }
     catch (err) {

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

@@ -76,7 +76,7 @@ module.exports = (crowi) => {
    *        200:
    *          description: Successfully connected
    */
-  router.post('/connection', accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+  router.post('/connection', accessTokenParser, loginRequired, adminRequired, addActivity, async(req, res) => {
     const { searchService } = crowi;
 
     if (!searchService.isConfigured) {
@@ -85,6 +85,9 @@ module.exports = (crowi) => {
 
     try {
       await searchService.reconnectClient();
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SEARCH_CONNECTION });
+
       return res.status(200).send();
     }
     catch (err) {

+ 26 - 7
packages/app/src/server/routes/apiv3/users.js

@@ -464,12 +464,15 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: data of admin user
    */
-  router.put('/:id/giveAdmin', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+  router.put('/:id/giveAdmin', loginRequiredStrictly, adminRequired, csrf, addActivity, async(req, res) => {
     const { id } = req.params;
 
     try {
       const userData = await User.findById(id);
       await userData.makeAdmin();
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_GIVE_ADMIN });
+
       return res.apiv3({ userData });
     }
     catch (err) {
@@ -506,12 +509,15 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: data of removed admin user
    */
-  router.put('/:id/removeAdmin', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+  router.put('/:id/removeAdmin', loginRequiredStrictly, adminRequired, csrf, addActivity, async(req, res) => {
     const { id } = req.params;
 
     try {
       const userData = await User.findById(id);
       await userData.removeFromAdmin();
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_REMOVE_ADMIN });
+
       return res.apiv3({ userData });
     }
     catch (err) {
@@ -547,7 +553,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: data of activate user
    */
-  router.put('/:id/activate', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+  router.put('/:id/activate', loginRequiredStrictly, adminRequired, csrf, addActivity, async(req, res) => {
     // check user upper limit
     const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
     if (isUserCountExceedsUpperLimit) {
@@ -561,6 +567,9 @@ module.exports = (crowi) => {
     try {
       const userData = await User.findById(id);
       await userData.statusActivate();
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_ACTIVATE });
+
       return res.apiv3({ userData });
     }
     catch (err) {
@@ -596,12 +605,15 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: data of deactivate user
    */
-  router.put('/:id/deactivate', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+  router.put('/:id/deactivate', loginRequiredStrictly, adminRequired, csrf, addActivity, async(req, res) => {
     const { id } = req.params;
 
     try {
       const userData = await User.findById(id);
       await userData.statusSuspend();
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_DEACTIVATE });
+
       return res.apiv3({ userData });
     }
     catch (err) {
@@ -637,7 +649,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: data of delete user
    */
-  router.delete('/:id/remove', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+  router.delete('/:id/remove', loginRequiredStrictly, adminRequired, csrf, addActivity, async(req, res) => {
     const { id } = req.params;
 
     try {
@@ -647,6 +659,8 @@ module.exports = (crowi) => {
       await ExternalAccount.remove({ user: userData });
       await Page.removeByPath(`/user/${userData.username}`);
 
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_REMOVE });
+
       return res.apiv3({ userData });
     }
     catch (err) {
@@ -814,7 +828,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: Target user
    */
-  router.put('/reset-password', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+  router.put('/reset-password', loginRequiredStrictly, adminRequired, csrf, addActivity, async(req, res) => {
     const { id } = req.body;
 
     try {
@@ -822,6 +836,8 @@ module.exports = (crowi) => {
         await User.resetPasswordByRandomString(id),
         await User.findById(id)]);
 
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_PASSWORD_RESET });
+
       return res.apiv3({ newPassword, user });
     }
     catch (err) {
@@ -859,7 +875,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: email and reasons for email sending failure
    */
-  router.put('/send-invitation-email', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+  router.put('/send-invitation-email', loginRequiredStrictly, adminRequired, csrf, addActivity, async(req, res) => {
     const { id } = req.body;
 
     try {
@@ -872,6 +888,9 @@ module.exports = (crowi) => {
       }];
       const sendEmail = await sendEmailByUserList(userList);
       // return null if absent
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_SEND_INVITATION_EMAIL });
+
       return res.apiv3({ failedToSendEmail: sendEmail.failedToSendEmailList[0] });
     }
     catch (err) {

+ 6 - 5
packages/app/src/server/routes/attachment.js

@@ -1,7 +1,8 @@
+
 import { SupportedAction } from '~/interfaces/activity';
+import { AttachmentType } from '~/server/interfaces/attachment';
 import loggerFactory from '~/utils/logger';
 
-
 /* eslint-disable no-use-before-define */
 
 
@@ -447,7 +448,7 @@ module.exports = function(crowi, app) {
     if (pageId == null && pagePath == null) {
       return res.json(ApiResponse.error('Either page_id or path is required.'));
     }
-    if (!req.file) {
+    if (req.file == null) {
       return res.json(ApiResponse.error('File error.'));
     }
 
@@ -473,7 +474,7 @@ module.exports = function(crowi, app) {
 
     let attachment;
     try {
-      attachment = await attachmentService.createAttachment(file, req.user, pageId);
+      attachment = await attachmentService.createAttachment(file, req.user, pageId, AttachmentType.WIKI_PAGE);
     }
     catch (err) {
       logger.error(err);
@@ -564,7 +565,7 @@ module.exports = function(crowi, app) {
    */
   api.uploadProfileImage = async function(req, res) {
     // check params
-    if (!req.file) {
+    if (req.file == null) {
       return res.json(ApiResponse.error('File error.'));
     }
     if (!req.user) {
@@ -582,7 +583,7 @@ module.exports = function(crowi, app) {
     let attachment;
     try {
       req.user.deleteImage();
-      attachment = await attachmentService.createAttachment(file, req.user);
+      attachment = await attachmentService.createAttachment(file, req.user, null, AttachmentType.PROFILE_IMAGE);
       await req.user.updateImage(attachment);
     }
     catch (err) {

+ 20 - 65
packages/app/src/server/routes/login-passport.js

@@ -20,7 +20,7 @@ module.exports = function(crowi, app) {
    * @param {*} req
    * @param {*} res
    */
-  const loginSuccessHandler = async(req, res, user) => {
+  const loginSuccessHandler = async(req, res, user, action) => {
     // update lastLoginAt
     user.updateLastLoginAt(new Date(), (err, userData) => {
       if (err) {
@@ -33,6 +33,17 @@ module.exports = function(crowi, app) {
     // remove session.redirectTo
     delete req.session.redirectTo;
 
+    const parameters = {
+      ip:  req.ip,
+      endpoint: req.originalUrl,
+      action,
+      user: req.user?._id,
+      snapshot: {
+        username: req.user.username,
+      },
+    };
+    await crowi.activityService.createActivity(parameters);
+
     return res.safeRedirect(redirectTo);
   };
 
@@ -141,10 +152,7 @@ module.exports = function(crowi, app) {
     await req.logIn(user, (err) => {
       if (err) { debug(err.message); return next() }
 
-      const parameters = { action: SupportedAction.ACTION_USER_LOGIN_WITH_LDAP };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-
-      return loginSuccessHandler(req, res, user);
+      return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_LDAP);
     });
   };
 
@@ -236,10 +244,7 @@ module.exports = function(crowi, app) {
       req.logIn(user, (err) => {
         if (err) { debug(err.message); return next() }
 
-        const parameters = { action: SupportedAction.ACTION_USER_LOGIN_WITH_LOCAL };
-        activityEvent.emit('update', res.locals.activity._id, parameters);
-
-        return loginSuccessHandler(req, res, user);
+        return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_LOCAL);
       });
     })(req, res, next);
   };
@@ -310,18 +315,7 @@ module.exports = function(crowi, app) {
     req.logIn(user, async(err) => {
       if (err) { debug(err.message); return next() }
 
-      const parameters = {
-        ip:  req.ip,
-        endpoint: req.originalUrl,
-        action: SupportedAction.ACTION_USER_LOGIN_WITH_GOOGLE,
-        user: req.user?._id,
-        snapshot: {
-          username: req.user?.username,
-        },
-      };
-      await crowi.activityService.createActivity(parameters);
-
-      return loginSuccessHandler(req, res, user);
+      return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_GOOGLE);
     });
   };
 
@@ -364,18 +358,7 @@ module.exports = function(crowi, app) {
     req.logIn(user, async(err) => {
       if (err) { debug(err.message); return next() }
 
-      const parameters = {
-        ip:  req.ip,
-        endpoint: req.originalUrl,
-        action: SupportedAction.ACTION_USER_LOGIN_WITH_GITHUB,
-        user: req.user?._id,
-        snapshot: {
-          username: req.user?.username,
-        },
-      };
-      await crowi.activityService.createActivity(parameters);
-
-      return loginSuccessHandler(req, res, user);
+      return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_GITHUB);
     });
   };
 
@@ -418,18 +401,7 @@ module.exports = function(crowi, app) {
     req.logIn(user, async(err) => {
       if (err) { debug(err.message); return next() }
 
-      const parameters = {
-        ip:  req.ip,
-        endpoint: req.originalUrl,
-        action: SupportedAction.ACTION_USER_LOGIN_WITH_TWITTER,
-        user: req.user?._id,
-        snapshot: {
-          username: req.user?.username,
-        },
-      };
-      await crowi.activityService.createActivity(parameters);
-
-      return loginSuccessHandler(req, res, user);
+      return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_TWITTER);
     });
   };
 
@@ -478,18 +450,7 @@ module.exports = function(crowi, app) {
     req.logIn(user, async(err) => {
       if (err) { debug(err.message); return next() }
 
-      const parameters = {
-        ip:  req.ip,
-        endpoint: req.originalUrl,
-        action: SupportedAction.ACTION_USER_LOGIN_WITH_OIDC,
-        user: req.user?._id,
-        snapshot: {
-          username: req.user?.username,
-        },
-      };
-      await crowi.activityService.createActivity(parameters);
-
-      return loginSuccessHandler(req, res, user);
+      return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_OIDC);
     });
   };
 
@@ -552,10 +513,7 @@ module.exports = function(crowi, app) {
         return loginFailureHandler(req, res);
       }
 
-      const parameters = { action: SupportedAction.ACTION_USER_LOGIN_WITH_SAML };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-
-      return loginSuccessHandler(req, res, user);
+      return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_SAML);
     });
   };
 
@@ -598,10 +556,7 @@ module.exports = function(crowi, app) {
     await req.logIn(user, (err) => {
       if (err) { debug(err.message); return next() }
 
-      const parameters = { action: SupportedAction.ACTION_USER_LOGIN_WITH_BASIC };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-
-      return loginSuccessHandler(req, res, user);
+      return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_BASIC);
     });
   };
 

+ 4 - 3
packages/app/src/server/service/attachment.js

@@ -1,8 +1,9 @@
 import loggerFactory from '~/utils/logger';
 
-const mongoose = require('mongoose');
 const fs = require('fs');
 
+const mongoose = require('mongoose');
+
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 const logger = loggerFactory('growi:service:AttachmentService');
 
@@ -15,7 +16,7 @@ class AttachmentService {
     this.crowi = crowi;
   }
 
-  async createAttachment(file, user, pageId = null) {
+  async createAttachment(file, user, pageId = null, attachmentType) {
     const { fileUploadService } = this.crowi;
 
     // check limit
@@ -33,7 +34,7 @@ class AttachmentService {
     // create an Attachment document and upload file
     let attachment;
     try {
-      attachment = Attachment.createWithoutSave(pageId, user, fileStream, file.originalname, file.mimetype, file.size);
+      attachment = Attachment.createWithoutSave(pageId, user, fileStream, file.originalname, file.mimetype, file.size, attachmentType);
       await fileUploadService.uploadFile(fileStream, attachment);
       await attachment.save();
     }

+ 18 - 0
packages/app/src/server/service/config-loader.ts

@@ -364,6 +364,24 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.BOOLEAN,
     default: false,
   },
+  TRUST_PROXY_BOOL: {
+    ns:      'crowi',
+    key:     'security:trustProxyBool',
+    type:    ValueType.BOOLEAN,
+    default: null,
+  },
+  TRUST_PROXY_CSV: {
+    ns:      'crowi',
+    key:     'security:trustProxyCsv',
+    type:    ValueType.STRING,
+    default: null,
+  },
+  TRUST_PROXY_HOPS: {
+    ns:      'crowi',
+    key:     'security:trustProxyHops',
+    type:    ValueType.NUMBER,
+    default: null,
+  },
   LOCAL_STRATEGY_ENABLED: {
     ns:      'crowi',
     key:     'security:passport-local:isEnabled',

+ 15 - 5
packages/app/src/server/service/page-operation.ts

@@ -1,7 +1,9 @@
 import { pagePathUtils } from '@growi/core';
 
-import { IPageOperationProcessInfo, IPageOperationProcessData } from '~/interfaces/page-operation';
-import PageOperation, { PageActionType, PageActionStage, PageOperationDocument } from '~/server/models/page-operation';
+import {
+  IPageOperationProcessInfo, IPageOperationProcessData, PageActionType, PageActionStage,
+} from '~/interfaces/page-operation';
+import PageOperation, { PageOperationDocument } from '~/server/models/page-operation';
 import loggerFactory from '~/utils/logger';
 
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
@@ -26,9 +28,10 @@ class PageOperationService {
   }
 
   async init(): Promise<void> {
-    // cleanup PageOperation documents except ones with actionType: Rename
+    // cleanup PageOperation documents except ones with { actionType: Rename, actionStage: Sub }
     const types = [Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent];
     await PageOperation.deleteByActionTypes(types);
+    await PageOperation.deleteMany({ actionType: PageActionType.Rename, actionStage: PageActionStage.Main });
   }
 
   /**
@@ -137,12 +140,19 @@ class PageOperationService {
       const isProcessable = pageOp.isProcessable();
 
       // processData for processInfo
-      const processData: IPageOperationProcessData = { [actionType]: { isProcessable } };
+      const mainProcessableInfo = pageOp.actionStage === PageActionStage.Main ? { isProcessable } : undefined;
+      const subProcessableInfo = pageOp.actionStage === PageActionStage.Sub ? { isProcessable } : undefined;
+      const processData: IPageOperationProcessData = {
+        [actionType]: {
+          [PageActionStage.Main]: mainProcessableInfo,
+          [PageActionStage.Sub]: subProcessableInfo,
+        },
+      };
 
       // Merge processData if other processData exist
       if (processInfo[pageId] != null) {
         const otherProcessData = processInfo[pageId];
-        processInfo[pageId] = { ...otherProcessData, ...processData };
+        processInfo[pageId] = Object.assign(otherProcessData, processData);
         return;
       }
       // add new process data to processInfo

+ 99 - 12
packages/app/src/server/service/page.ts

@@ -10,12 +10,14 @@ import { Ref } from '~/interfaces/common';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import { HasObjectId } from '~/interfaces/has-object-id';
 import {
-  IPage, IPageInfo, IPageInfoForEntity, IPageWithMeta,
+  IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta,
 } from '~/interfaces/page';
 import {
   PageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation,
 } from '~/interfaces/page-delete-config';
-import { IPageOperationProcessInfo, IPageOperationProcessData } from '~/interfaces/page-operation';
+import {
+  IPageOperationProcessInfo, IPageOperationProcessData, PageActionStage, PageActionType,
+} from '~/interfaces/page-operation';
 import { IUserHasId } from '~/interfaces/user';
 import { PageMigrationErrorData, SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
 import {
@@ -27,7 +29,7 @@ import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { PathAlreadyExistsError } from '../models/errors';
-import PageOperation, { PageActionStage, PageActionType, PageOperationDocument } from '../models/page-operation';
+import PageOperation, { PageOperationDocument } from '../models/page-operation';
 import { PageRedirectModel } from '../models/page-redirect';
 import { serializePageSecurely } from '../models/serializers/page-serializer';
 import Subscription from '../models/subscription';
@@ -216,7 +218,9 @@ class PageService {
   }
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  async findPageAndMetaDataByViewer(pageId: string, path: string, user: IUserHasId, includeEmpty = false, isSharedPage = false): Promise<IPageWithMeta|null> {
+  async findPageAndMetaDataByViewer(
+      pageId: string, path: string, user: IUserHasId, includeEmpty = false, isSharedPage = false,
+  ): Promise<IPageWithMeta<IPageInfoAll>|null> {
 
     const Page = this.crowi.model('Page');
 
@@ -399,7 +403,19 @@ class PageService {
       logger.error('Failed to create PageOperation document.', err);
       throw err;
     }
-    const renamedPage = await this.renameMainOperation(page, newPagePath, user, options, pageOp._id);
+
+    let renamedPage: PageDocument | null = null;
+    try {
+      renamedPage = await this.renameMainOperation(page, newPagePath, user, options, pageOp._id);
+    }
+    catch (err) {
+      logger.error('Error occurred while running renameMainOperation', err);
+
+      // cleanup
+      await PageOperation.deleteOne({ _id: pageOp._id });
+
+      throw err;
+    }
 
     return renamedPage;
   }
@@ -994,7 +1010,20 @@ class PageService {
         logger.error('Failed to create PageOperation document.', err);
         throw err;
       }
-      this.duplicateRecursivelyMainOperation(page, newPagePath, user, pageOp._id);
+
+      (async() => {
+        try {
+          await this.duplicateRecursivelyMainOperation(page, newPagePath, user, pageOp._id);
+        }
+        catch (err) {
+          logger.error('Error occurred while running duplicateRecursivelyMainOperation.', err);
+
+          // cleanup
+          await PageOperation.deleteOne({ _id: pageOp._id });
+
+          throw err;
+        }
+      })();
     }
 
     const result = serializePageSecurely(duplicatedTarget);
@@ -1385,7 +1414,19 @@ class PageService {
       /*
        * Resumable Operation
        */
-      this.deleteRecursivelyMainOperation(page, user, pageOp._id);
+      (async() => {
+        try {
+          await this.deleteRecursivelyMainOperation(page, user, pageOp._id);
+        }
+        catch (err) {
+          logger.error('Error occurred while running deleteRecursivelyMainOperation.', err);
+
+          // cleanup
+          await PageOperation.deleteOne({ _id: pageOp._id });
+
+          throw err;
+        }
+      })();
     }
 
     return deletedPage;
@@ -1701,7 +1742,19 @@ class PageService {
       /*
        * Main Operation
        */
-      this.deleteCompletelyRecursivelyMainOperation(page, user, options, pageOp._id);
+      (async() => {
+        try {
+          await this.deleteCompletelyRecursivelyMainOperation(page, user, options, pageOp._id);
+        }
+        catch (err) {
+          logger.error('Error occurred while running deleteCompletelyRecursivelyMainOperation.', err);
+
+          // cleanup
+          await PageOperation.deleteOne({ _id: pageOp._id });
+
+          throw err;
+        }
+      })();
     }
 
     return;
@@ -1909,7 +1962,19 @@ class PageService {
       /*
        * Resumable Operation
        */
-      this.revertRecursivelyMainOperation(page, user, options, pageOp._id);
+      (async() => {
+        try {
+          await this.revertRecursivelyMainOperation(page, user, options, pageOp._id);
+        }
+        catch (err) {
+          logger.error('Error occurred while running revertRecursivelyMainOperation.', err);
+
+          // cleanup
+          await PageOperation.deleteOne({ _id: pageOp._id });
+
+          throw err;
+        }
+      })();
     }
 
     return updatedPage;
@@ -2108,6 +2173,7 @@ class PageService {
 
     const likers = page.liker.slice(0, 15) as Ref<IUserHasId>[];
     const seenUsers = page.seenUsers.slice(0, 15) as Ref<IUserHasId>[];
+    const expandContentWidth = page.expandContentWidth ?? this.crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
 
     return {
       isV5Compatible: isTopPage(page.path) || page.parent != null,
@@ -2120,6 +2186,7 @@ class PageService {
       isDeletable: isMovable,
       isAbleToDeleteCompletely: false,
       isRevertible: isTrashPage(page.path),
+      expandContentWidth,
     };
 
   }
@@ -2293,7 +2360,19 @@ class PageService {
       throw err;
     }
 
-    this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
+    (async() => {
+      try {
+        await this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
+      }
+      catch (err) {
+        logger.error('Error occurred while running normalizeParentRecursivelyMainOperation.', err);
+
+        // cleanup
+        await PageOperation.deleteOne({ _id: pageOp._id });
+
+        throw err;
+      }
+    })();
   }
 
   async normalizeParentByPageIdsRecursively(pageIds: ObjectIdLike[], user): Promise<void> {
@@ -2478,7 +2557,11 @@ class PageService {
       }
       catch (err) {
         errorPagePaths.push(page.path);
-        logger.err('Failed to run normalizeParentRecursivelyMainOperation.', err);
+        logger.error('Failed to run normalizeParentRecursivelyMainOperation.', err);
+
+        // cleanup
+        await PageOperation.deleteOne({ _id: pageOp._id });
+
         throw err;
       }
     }
@@ -3273,6 +3356,8 @@ class PageService {
   async create(path: string, body: string, user, options: PageCreateOptions = {}): Promise<PageDocument> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
+    const expandContentWidth = this.crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
+
     // Switch method
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     if (!isV5Compatible) {
@@ -3319,7 +3404,9 @@ class PageService {
       const parent = await this.getParentAndFillAncestorsByUser(user, path);
       page.parent = parent._id;
     }
-
+    if (expandContentWidth != null) {
+      page.expandContentWidth = expandContentWidth;
+    }
     // Save
     let savedPage = await page.save();
 

+ 19 - 19
packages/app/src/server/service/search.ts

@@ -1,24 +1,24 @@
-import xss from 'xss';
 import mongoose from 'mongoose';
+import xss from 'xss';
 
 import { SearchDelegatorName } from '~/interfaces/named-query';
-import { IPageWithMeta } from '~/interfaces/page';
-import { IFormattedSearchResult, IPageSearchMeta, ISearchResult } from '~/interfaces/search';
+import { IPageHasId } from '~/interfaces/page';
+import { IFormattedSearchResult, IPageWithSearchMeta, ISearchResult } from '~/interfaces/search';
 import loggerFactory from '~/utils/logger';
 
-import NamedQuery from '../models/named-query';
+import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import {
   SearchDelegator, SearchQueryParser, SearchResolver, ParsedQuery, SearchableData, QueryTerms,
 } from '../interfaces/search';
-import ElasticsearchDelegator from './search-delegator/elasticsearch';
-import PrivateLegacyPagesDelegator from './search-delegator/private-legacy-pages';
-
+import NamedQuery from '../models/named-query';
 import { PageModel } from '../models/page';
 import { serializeUserSecurely } from '../models/serializers/user-serializer';
-
-import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { SearchError } from '../models/vo/search-error';
 
+import ElasticsearchDelegator from './search-delegator/elasticsearch';
+import PrivateLegacyPagesDelegator from './search-delegator/private-legacy-pages';
+
+
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:service:search');
 
@@ -71,17 +71,17 @@ const findPageListByIds = async(pageIds: ObjectIdLike[], crowi: any) => {
 
 class SearchService implements SearchQueryParser, SearchResolver {
 
-  crowi!: any
+  crowi!: any;
 
-  configManager!: any
+  configManager!: any;
 
-  isErrorOccuredOnHealthcheck: boolean | null
+  isErrorOccuredOnHealthcheck: boolean | null;
 
-  isErrorOccuredOnSearching: boolean | null
+  isErrorOccuredOnSearching: boolean | null;
 
-  fullTextSearchDelegator: any & ElasticsearchDelegator
+  fullTextSearchDelegator: any & ElasticsearchDelegator;
 
-  nqDelegators: {[key in SearchDelegatorName]: SearchDelegator}
+  nqDelegators: {[key in SearchDelegatorName]: SearchDelegator};
 
   constructor(crowi) {
     this.crowi = crowi;
@@ -400,9 +400,9 @@ class SearchService implements SearchQueryParser, SearchResolver {
    */
   async formatSearchResult(searchResult: ISearchResult<any>, delegatorName: SearchDelegatorName, user, userGroups): Promise<IFormattedSearchResult> {
     if (!this.checkIsFormattable(searchResult, delegatorName)) {
-      const data: IPageWithMeta<IPageSearchMeta>[] = searchResult.data.map((page) => {
+      const data: IPageWithSearchMeta[] = searchResult.data.map((page) => {
         return {
-          data: page,
+          data: page as IPageHasId,
         };
       });
 
@@ -419,7 +419,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
     const result = {} as IFormattedSearchResult;
 
     // get page data
-    const pageIds = searchResult.data.map((page) => { return page._id });
+    const pageIds: string[] = searchResult.data.map((page) => { return page._id });
 
     const findPageResult = await findPageListByIds(pageIds, this.crowi);
 
@@ -427,7 +427,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
     result.meta = searchResult.meta;
 
     // set search result page data
-    const pages: (IPageWithMeta<IPageSearchMeta> | null)[] = searchResult.data.map((data) => {
+    const pages: (IPageWithSearchMeta | null)[] = searchResult.data.map((data) => {
       const pageData = findPageResult.pages.find((pageData) => {
         return pageData.id === data._id;
       });

+ 3 - 0
packages/app/src/server/util/rate-limiter.ts

@@ -20,8 +20,10 @@ const generateApiRateLimitConfigFromEndpoint = (envVar: NodeJS.ProcessEnv, targe
     }
     const methodKey = `API_RATE_LIMIT_${target}_METHODS`;
     const maxRequestsKey = `API_RATE_LIMIT_${target}_MAX_REQUESTS`;
+    const usersPerIpProspectionKey = `API_RATE_LIMIT_${target}_USERS_PER_IP`;
     const method = envVar[methodKey] ?? 'ALL';
     const maxRequests = Number(envVar[maxRequestsKey]);
+    const usersPerIpProspection = Number(envVar[usersPerIpProspectionKey]);
 
     if (endpoint == null || maxRequests == null) {
       return;
@@ -30,6 +32,7 @@ const generateApiRateLimitConfigFromEndpoint = (envVar: NodeJS.ProcessEnv, targe
     const config = {
       method,
       maxRequests,
+      usersPerIpProspection,
     };
 
     apiRateLimitConfig[endpoint] = config;

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff