Explorar el Código

Merge commit '20f4df18730be4e732c2da74eea2654e9856a9a5' into imprv/reactify-admin-user-groups-detail

mizozobu hace 6 años
padre
commit
5fc7ae3a88
Se han modificado 86 ficheros con 1808 adiciones y 412 borrados
  1. 0 4
      .eslintrc.js
  2. 49 21
      CHANGES.md
  3. 5 0
      README.md
  4. 3 1
      bin/download-cdn-resources.js
  5. 0 0
      checkout
  6. 1 1
      config/env.dev.js
  7. 2 5
      config/jest.config.js
  8. 0 0
      master
  9. 12 10
      package.json
  10. 2 0
      resource/cdn-manifests.js
  11. 25 21
      resource/locales/en-US/translation.json
  12. 1 1
      resource/locales/en-US/welcome.md
  13. 19 16
      resource/locales/ja/translation.json
  14. 1 11
      src/client/js/app.jsx
  15. 0 0
      src/client/js/components/Admin/CustomCssEditor.jsx
  16. 0 0
      src/client/js/components/Admin/CustomHeaderEditor.jsx
  17. 0 0
      src/client/js/components/Admin/CustomScriptEditor.jsx
  18. 170 3
      src/client/js/components/Admin/Importer.jsx
  19. 56 0
      src/client/js/components/Admin/Users/GiveAdminForm.jsx
  20. 124 0
      src/client/js/components/Admin/Users/PasswordResetModal.jsx
  21. 68 0
      src/client/js/components/Admin/Users/RemoveAdminForm.jsx
  22. 72 0
      src/client/js/components/Admin/Users/StatusActivateForm.jsx
  23. 69 0
      src/client/js/components/Admin/Users/StatusSuspendedForm.jsx
  24. 79 0
      src/client/js/components/Admin/Users/UserMenu.jsx
  25. 56 0
      src/client/js/components/Admin/Users/UserRemoveForm.jsx
  26. 80 0
      src/client/js/components/Admin/Users/UserTable.jsx
  27. 59 2
      src/client/js/components/Admin/Users/Users.jsx
  28. 0 0
      src/client/js/components/Page/PagePath.jsx
  29. 0 0
      src/client/js/components/Page/RevisionBody.jsx
  30. 12 18
      src/client/js/components/Page/RevisionLoader.jsx
  31. 0 0
      src/client/js/components/PageAttachment/Attachment.jsx
  32. 0 0
      src/client/js/components/PageAttachment/DeleteAttachmentModal.jsx
  33. 0 0
      src/client/js/components/PageAttachment/PageAttachmentList.jsx
  34. 3 2
      src/client/js/components/PageEditor.jsx
  35. 0 0
      src/client/js/components/PageEditor/MarkdownTableUtil.js
  36. 2 2
      src/client/js/components/PageEditor/ScrollSyncHelper.js
  37. 0 0
      src/client/js/components/PageList/ListView.jsx
  38. 9 1
      src/client/js/components/PageList/Page.jsx
  39. 0 0
      src/client/js/components/PageList/PageListMeta.jsx
  40. 0 0
      src/client/js/components/PageList/PagePath.jsx
  41. 1 1
      src/client/js/components/SavePageControls/GrantSelector.jsx
  42. 0 0
      src/client/js/components/SearchForm.jsx
  43. 0 0
      src/client/js/components/SearchPage.jsx
  44. 0 0
      src/client/js/components/SearchPage/DeletePageListModal.jsx
  45. 0 0
      src/client/js/components/SearchPage/SearchPageForm.jsx
  46. 0 0
      src/client/js/components/SearchPage/SearchResult.jsx
  47. 2 2
      src/client/js/components/SearchPage/SearchResultList.jsx
  48. 0 0
      src/client/js/components/SearchTypeahead.jsx
  49. 3 3
      src/client/js/components/StaffCredit/StaffCredit.jsx
  50. 21 21
      src/client/js/legacy/crowi.js
  51. 4 2
      src/client/js/models/MarkdownTable.js
  52. 8 5
      src/client/styles/scss/_on-edit.scss
  53. 6 4
      src/client/styles/scss/_search.scss
  54. 15 12
      src/lib/service/cdn-resources-service.js
  55. 3 1
      src/lib/service/logger/stream.prod.js
  56. 32 0
      src/migrations/20190619055421-adjust-page-grant.js
  57. 1 1
      src/server/form/admin/securityGeneral.js
  58. 10 2
      src/server/models/GlobalNotificationSetting/index.js
  59. 1 1
      src/server/models/config.js
  60. 5 1
      src/server/models/page-tag-relation.js
  61. 18 14
      src/server/models/page.js
  62. 29 48
      src/server/models/user.js
  63. 31 24
      src/server/routes/admin.js
  64. 12 7
      src/server/routes/page.js
  65. 38 12
      src/server/service/acl.js
  66. 9 1
      src/server/service/config-loader.js
  67. 4 2
      src/server/util/middlewares.js
  68. 1 1
      src/server/util/search.js
  69. 1 1
      src/server/util/swigFunctions.js
  70. 3 3
      src/server/views/admin/external-accounts.html
  71. 34 2
      src/server/views/admin/importer.html
  72. 12 4
      src/server/views/admin/security.html
  73. 4 4
      src/server/views/admin/user-group-detail.html
  74. 15 0
      src/server/views/admin/users.html
  75. 1 1
      src/server/views/layout/layout.html
  76. 1 1
      src/server/views/me/external-accounts.html
  77. 13 11
      src/server/views/modal/delete.html
  78. 21 9
      src/server/views/modal/rename.html
  79. 1 1
      src/server/views/search.html
  80. 1 1
      src/server/views/widget/page_tabs.html
  81. 1 1
      src/server/views/widget/page_tabs_kibela.html
  82. 0 8
      src/test/models/page.test.js
  83. 22 20
      src/test/models/user.test.js
  84. 205 0
      src/test/service/acl.test.js
  85. 186 0
      src/test/util/middlewares.test.js
  86. 54 61
      yarn.lock

+ 0 - 4
.eslintrc.js

@@ -30,10 +30,6 @@ module.exports = {
         FunctionExpression: { body: 1, parameters: 2 },
       },
     ],
-    'react/jsx-filename-extension': [
-      'warn',
-      { extensions: ['.jsx']},
-    ],
     // eslint-plugin-import rules
     'import/no-unresolved': [2, { ignore: ['^@'] }], // ignore @alias/..., @commons/..., ...
   },

+ 49 - 21
CHANGES.md

@@ -1,31 +1,41 @@
 # CHANGES
 
-## 3.5.1-RC
+## 3.5.5-RC
 
-### BREAKING CHANGES
+* Fix: Profile images are broken in User Management
+* Support: Upgrade libs
+    * csv-to-markdown-table
+    * express-validator
+    * markdown-it
+    * mini-css-extract-plugin
+    * react-hotkeys
 
-* GROWI no longer supports plugins with schema version 2
-    * Upgrade [weseek/growi-plugin-lsx](https://github.com/weseek/growi-plugin-lsx) to v3.0.0 or above
-    * Upgrade [weseek/growi-plugin-pukiwiki-like-linker
-](https://github.com/weseek/growi-plugin-pukiwiki-like-linker
-) to v3.0.0 or above
-* The restriction mode of the root page (`/`) will be set 'Public'
-* The restriction mode of the root page (`/`) can not be changed after v 3.5.1
+## 3.5.4
 
-### Updates
+* Fix: List private pages wrongly
+* Fix: Global Notification Trigger Path does not parse glob correctly
+* Fix: Consecutive page deletion requests cause unexpected complete page deletion
 
-* Support: Use Babel 7
-* Support: Support plugins with schema version 3
-* Fix: Could not edit UserGroup even if `PUBLIC_WIKI_ONLY` is not set
-* Upgrade libs
-    * css-loader
-    * eslint
-    * eslint-config-weseek
-    * eslint-plugin-import
-    * eslint-plugin-jest
-    * eslint-plugin-react
+## 3.5.3
+
+* Improvement: Calculate string width when save with Spreadsheet like GUI (Handsontable)
+* Fix: Search Result Page doesn't work
+* Fix: Create/Update page API returns data includes author's password hash
+* Fix: Dropdown to copy page path/URL/MarkdownLink shows under CodeMirror vscrollbar
+* Fix: Link to /trash in Dropdown menu
 
-## 3.5.0
+## 3.5.2
+
+* Feature: Remain metadata option when Move/Rename page
+* Improvement: Support code highlight for Swift and Kotlin
+* Fix: Couldn't restrict page with user group permission
+* Fix: Couldn't duplicate a page when it restricted by a user group permission
+* Fix: Consider timezone on admin page
+* Fix: Editor doesn't work on Microsoft Edge
+* Support: Upgrade libs
+    * growi-commons
+
+## 3.5.1
 
 ### BREAKING CHANGES
 
@@ -33,6 +43,13 @@
     * Protection system with Basic Authentication
     * Crowi Classic Authentication Mechanism
     * [Crowi Template syntax](https://medium.com/crowi-book/crowi-v1-5-0-5a62e7c6be90)
+* GROWI no lonnger supports plugins with schema version 2
+    * Upgrade [weseek/growi-plugin-lsx](https://github.com/weseek/growi-plugin-lsx) to v3.0.0 or above
+    * Upgrade [weseek/growi-plugin-pukiwiki-like-linker
+](https://github.com/weseek/growi-plugin-pukiwiki-like-linker
+) to v3.0.0 or above
+* The restriction mode of the root page (`/`) will be set 'Public'
+* The restriction mode of the root page (`/`) can not be changed after v 3.5.1
 
 Upgrading Guide: https://docs.growi.org/guide/upgrading/35x.html
 
@@ -46,15 +63,24 @@ Upgrading Guide: https://docs.growi.org/guide/upgrading/35x.html
 * Improvement Draft list
 * Fix: Deleting page completely
 * Fix: Search with `prefix:` param with CJK pathname
+* Fix: Could not edit UserGroup even if `PUBLIC_WIKI_ONLY` is not set
 * I18n: User Management Details
 * I18n: Group Management Details
 * Support: Apply unstated
+* Support: Use Babel 7
+* Support: Support plugins with schema version 3
 * Support: Abolish Old Config API
 * Support: Apply Jest for Tests
 * Support: Upgrade libs
     * async
     * axios
     * connect-mongo
+    * css-loader
+    * eslint
+    * eslint-config-weseek
+    * eslint-plugin-import
+    * eslint-plugin-jest
+    * eslint-plugin-react
     * file-loader
     * googleapis
     * i18next
@@ -65,6 +91,8 @@ Upgrading Guide: https://docs.growi.org/guide/upgrading/35x.html
     * mongoose-unique-validator
     * null-loader
 
+## 3.5.0 (Missing number)
+
 ## 3.4.7
 
 * Improvement: Handle private pages on group deletion

+ 5 - 0
README.md

@@ -172,6 +172,11 @@ Environment Variables
     * MONGO_GRIDFS_TOTAL_LIMIT: Total capacity limit of MongoDB GridFS (bytes). default: `Infinity`
     * SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: If `true`, the system uses only the value of the environment variable as the value of the SAML option that can be set via the environment variable.
     * PUBLISH_OPEN_API: Publish GROWI OpenAPI resources with [ReDoc](https://github.com/Rebilly/ReDoc). Visit `/api-docs`.
+    * FORCE_WIKI_MODE: Forces wiki mode. default: undefined
+      * `public`  : Forces all pages to become public
+      * `private` : Forces all pages to become private
+      * undefined : Publicity will be configured by the admin security page settings
+    * FORMAT_NODE_LOG: If `false`, Output server log as JSON. defautl: `true` (Enabled only when `NODE_ENV=production`)
 * **Option to integrate with external systems**
     * HACKMD_URI: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server.
         * **This server must load the GROWI agent. [Here's how to prepare it](https://docs.growi.org/guide/admin-cookbook/integrate-with-hackmd.html).**

+ 3 - 1
bin/download-cdn-resources.js

@@ -7,8 +7,10 @@ require('module-alias/register');
 
 const logger = require('@alias/logger')('growi:bin:download-cdn-resources');
 
+const { envUtils } = require('growi-commons');
+
 // check env var
-const noCdn = !!process.env.NO_CDN;
+const noCdn = envUtils.toBoolean(process.env.NO_CDN);
 if (!noCdn) {
   logger.info('Using CDN. No resources are downloaded.');
   // exit

+ 0 - 0
checkout


+ 1 - 1
config/env.dev.js

@@ -13,5 +13,5 @@ module.exports = {
   // PUBLISH_OPEN_API: true,
   // USER_UPPER_LIMIT: 0,
   // DEV_HTTPS: true,
-  // PUBLIC_WIKI_ONLY: true,
+  // FORCE_WIKI_MODE: 'private', // 'public', 'private', undefined
 };

+ 2 - 5
config/jest.config.js

@@ -7,11 +7,6 @@ module.exports = {
 
   rootDir: '../',
 
-  // Automatically clear mock calls and instances between every test
-  clearMocks: true,
-  // Automatically reset mock state between every test
-  resetMocks: true,
-
   projects: [
     {
       displayName: 'server',
@@ -19,6 +14,8 @@ module.exports = {
       rootDir: '.',
       setupFilesAfterEnv: ['<rootDir>/src/test/setup.js'],
       testMatch: ['<rootDir>/src/test/**/*.test.js'],
+      // Automatically clear mock calls and instances between every test
+      clearMocks: true,
       // A map from regular expressions to module names that allow to stub out resources with a single module
       moduleNameMapper: {
         '@root/(.+)': '<rootDir>/$1',

+ 0 - 0
master


+ 12 - 10
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.5.1-RC",
+  "version": "3.5.5-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -32,8 +32,8 @@
     "clean:report": "rimraf -- report",
     "clean": "npm-run-all -p clean:*",
     "heroku-postbuild": "sh bin/heroku/install-plugins.sh && npm run build:prod",
-    "lint:js:fix": "eslint '**/*.{js,jsx}' --fix",
-    "lint:js": "eslint '**/*.{js,jsx}'",
+    "lint:js:fix": "eslint \"**/*.{js,jsx}\" --fix",
+    "lint:js": "eslint \"**/*.{js,jsx}\"",
     "lint:styles:fix": "prettier-stylelint --quiet --write src/client/styles/scss/**/*.scss",
     "lint:styles": "stylelint src/client/styles/scss/**/*.scss",
     "lint": "npm-run-all -p lint:js lint:styles",
@@ -62,6 +62,7 @@
   "dependencies": {
     "//": [
       "check-node-version: see https://github.com/parshap/check-node-version/issues/35",
+      "entities: markdown-it@9.0.1 depends on entities@~1.1.1",
       "mongoose: somehow GlobalNotificationSetting CRUD does not work with mongoose v5.6.0"
     ],
     "async": "^3.0.1",
@@ -89,10 +90,10 @@
     "express-form": "~0.12.0",
     "express-sanitizer": "^1.0.4",
     "express-session": "^1.16.1",
-    "express-validator": "^5.3.1",
+    "express-validator": "^6.1.1",
     "express-webpack-assets": "^0.1.0",
     "graceful-fs": "^4.1.11",
-    "growi-commons": "^4.0.1",
+    "growi-commons": "^4.0.3",
     "helmet": "^3.13.0",
     "i18next": "^17.0.3",
     "i18next-express-middleware": "^1.4.1",
@@ -152,9 +153,9 @@
     "colors": "^1.2.5",
     "commander": "^2.11.0",
     "connect-browser-sync": "^2.1.0",
-    "core-js": "^2.6.9",
+    "core-js": "=2.6.9",
     "css-loader": "^3.0.0",
-    "csv-to-markdown-table": "^0.5.0",
+    "csv-to-markdown-table": "^1.0.1",
     "date-fns": "^1.29.0",
     "diff2html": "^2.3.3",
     "eazy-logger": "^3.0.2",
@@ -168,12 +169,13 @@
     "i18next-browser-languagedetector": "^3.0.1",
     "imports-loader": "^0.8.0",
     "jest": "^24.8.0",
+    "jest-each": "^24.8.0",
     "jquery-slimscroll": "^1.3.8",
     "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
     "load-css-file": "^1.0.0",
     "lodash-webpack-plugin": "^0.11.5",
-    "markdown-it": "^8.4.0",
+    "markdown-it": "^9.0.1",
     "markdown-it-blockdiag": "^1.0.2",
     "markdown-it-emoji": "^1.4.0",
     "markdown-it-footnote": "^3.0.1",
@@ -184,7 +186,7 @@
     "markdown-it-toc-and-anchor-with-slugid": "^1.1.4",
     "markdown-table": "^1.1.1",
     "metismenu": "^3.0.3",
-    "mini-css-extract-plugin": "^0.7.0",
+    "mini-css-extract-plugin": "^0.8.0",
     "morgan": "^1.9.0",
     "node-dev": "^4.0.0",
     "node-sass": "^4.11.0",
@@ -204,7 +206,7 @@
     "react-dom": "^16.8.3",
     "react-dropzone": "^10.1.3",
     "react-frame-component": "^4.0.0",
-    "react-hotkeys": "^1.1.4",
+    "react-hotkeys": "^2.0.0",
     "react-i18next": "^10.6.1",
     "react-waypoint": "^9.0.0",
     "replacestream": "^4.0.3",

+ 2 - 0
resource/cdn-manifests.js

@@ -28,6 +28,8 @@ module.exports = {
         + 'gh/highlightjs/cdn-release@9.13.0/build/languages/scss.min.js,'
         + 'gh/highlightjs/cdn-release@9.13.0/build/languages/typescript.min.js,'
         + 'gh/highlightjs/cdn-release@9.13.0/build/languages/yaml.min.js,'
+        + 'gh/highlightjs/cdn-release@9.13.0/build/languages/swift.min.js,'
+        + 'gh/highlightjs/cdn-release@9.13.0/build/languages/kotlin.min.js,'
         + 'npm/highlightjs-line-numbers.js@2.6.0/dist/highlightjs-line-numbers.min.js',
       args: {
         async: true,

+ 25 - 21
resource/locales/en-US/translation.json

@@ -6,7 +6,7 @@
   "Duplicate": "Duplicate",
   "Copy": "Copy",
   "Click to copy": "Click to copy",
-  "Move": "Move",
+  "Move/Rename": "Move/Rename",
   "Moved": "Moved",
   "Unlinked": "Unlinked",
   "Like!": "Like!",
@@ -27,6 +27,8 @@
   "Category": "Category",
   "User": "User",
   "status":"Status",
+  "account_id": "Account Id",
+
 
   "Update": "Update",
   "Update Page": "Update Page",
@@ -105,13 +107,12 @@
   "Customize": "Customize",
   "Notification Settings": "Notification Settings",
   "User_Management": "User Management",
-  "External Account management": "External Account management",
+  "external_account_management": "External Account Management",
   "UserGroup Management": "UserGroup Management",
   "Full Text Search Management": "Full Text Search Management",
   "Import Data": "Import Data",
   "Basic Settings": "Basic Settings",
   "Basic authentication": "Basic authentication",
-  "Guest users access": "Guest users access",
   "Register limitation": "Register limitation",
   "The contents entered here will be shown in the header etc": "The contents entered here will be shown in the header etc",
   "Public": "Public",
@@ -267,15 +268,17 @@
 
   "modal_rename": {
     "label": {
-      "Rename page": "Rename page",
+      "Move/Rename page": "Move/Rename page",
       "New page name": "New page name",
       "Current page name": "Current page name",
-      "Move recursively": "Move recursively",
+      "Recursively": "Recursively",
+      "Do not update metadata": "Do not update metadata",
       "Redirect": "Redirect"
     },
     "help": {
       "redirect": "Redirect to new page if someone accesses <code>%s</code>",
-      "recursive": "Rename children of under <code>%s</code> recursively"
+      "metadata": "Remains last update user and updated date as is",
+      "recursive": "Move/Rename children of under <code>%s</code> recursively"
     }
   },
 
@@ -285,9 +288,9 @@
   "modal_delete": {
     "delete_page": "Delete Page",
     "deleting_page": "Deleting Page",
-    "delete_recursively": "Delete child pages under %s recursively.",
+    "delete_recursively": "Delete child pages recursively.",
     "delete_completely": "Delete Completely",
-    "delete_completely_restriction": "You have no admin to delete completely.",
+    "delete_completely_restriction": "You don't have the authority to delete pages completely.",
     "recursively": "Delete children of under <code>%s</code> recursively.",
     "completely": "Delete completely instead of putting it into trash."
   },
@@ -433,14 +436,12 @@
   },
 
   "security_setting": {
-		"Basic authentication": "Basic Authentication",
 		"Security settings": "Security settings",
-		"Guest users access": "Guest users access",
-		"Register limitation": "Register limitation",
+    "Guest Users Access": "Guest Users Access",
+    "Fixed by env var": "This is fixed by the env var <code>%s=%s</code>.",
+    "Register limitation": "Register limitation",
+    "Register limitation desc": "Restricts ways to register new user.",
 		"The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
-		"common_authentication": "If you set the basic authentication, common authentication is applied on the whole page.",
-		"without_encryption": "Please be noted that your ID and Password will be sent wihtout encryption.",
-		"basic_acl_disable": "Because of Public Wiki  setting, basic authentication can not be used.",
 		"users_without_account": "Users without account is not accessible",
     "example": "Example",
     "restrict_emails": "You can restrict registerable e-mail address.",
@@ -469,13 +470,13 @@
     "clientID": "Client ID",
     "client_secret": "Client Secret",
     "guest_mode": {
-      "deny": "Deny Unregistered Users",
-      "readonly": "View Only"
+      "deny": "Deny (Registered Users Only)",
+      "readonly": "Accept (Guests can read only)"
     },
     "registration_mode": {
-      "open": "Anyone",
-      "restricted": "Require Admin permission",
-      "closed": "Invitation Only"
+      "open": "Open (Anyone can registre)",
+      "restricted": "Restricted (Requires approval by administrators)",
+      "closed": "Closed (Invitation Only)"
     },
     "configuration": " Configuration",
     "optional": "Optional",
@@ -666,6 +667,7 @@
 
   "user_management": {
     "target_user": "Target User",
+    "new_password": "New Password",
     "invite_users": "Invite New Users",
     "emails": "Emails",
     "invite_thru_email": "Send Invitation Email",
@@ -685,6 +687,7 @@
     "unset": "No",
     "temporary_password": "The created user has a temporary password",
     "send_temporary_password": "Be sure to copy the temporary password ON THIS SCREEN and send it to the user.",
+    "password_reset_message": "Let the user know the new password below and strongly recommend to change another one immediately.",
     "send_new_password": "Please send the new password to the user.",
     "password_never_seen": "The temporary password can never be retrieved after this screen is closed.",
     "reset_password": "Reset Password",
@@ -718,11 +721,12 @@
     "no_pages": "There are no pages the group has view permission",
     "how_to_add1": "Enter a username to add",
     "how_to_add2": "Select a user from user list",
-    "remove_from_group": "Remove this group"
+    "remove_from_group": "Remove this user"
   },
 
   "importer_management": {
-    "import_from": "Import from %s",
+    "import_from_esa": "Import from esa.io",
+    "import_from_qiita": "import_from Qiita:Team",
     "esa_settings": {
       "team_name": "Team name",
       "access_token": "Access token",

+ 1 - 1
resource/locales/en-US/welcome.md

@@ -7,7 +7,7 @@
   <div class="panel-heading">Tips</div>
   <div class="panel-body"><ul>
     <li>Ctrl(⌘)-/ to show quick help</li>
-    <li>You can <a href="https://getbootstrap.com/docs/3.3/css/">Bootstrap 3</a> to write HTML tags.</li>
+    <li>You can write HTML with <a href="https://getbootstrap.com/docs/3.3/css/">Bootstrap 3</a>.</li>
   </ul></div>
 </div>
 

+ 19 - 16
resource/locales/ja/translation.json

@@ -6,7 +6,7 @@
   "Duplicate": "複製",
   "Copy": "コピー",
   "Click to copy": "クリックでコピー",
-  "Move": "移動",
+  "Move/Rename": "移動/名前変更",
   "Moved": "移動しました",
   "Unlinked": "リダイレクト削除",
   "Like!": "いいね!",
@@ -27,6 +27,7 @@
   "Category": "カテゴリー",
   "User": "ユーザー",
   "status": "ステータス",
+  "account_id": "アカウントID",
 
   "Update": "更新",
   "Update Page": "ページを更新",
@@ -105,13 +106,11 @@
   "Customize": "カスタマイズ",
   "Notification Settings": "通知設定",
   "User_Management": "ユーザー管理",
-  "External Account management": "外部アカウント管理",
+  "external_account_management": "外部アカウント管理",
   "UserGroup Management": "グループ管理",
   "Full Text Search Management": "全文検索管理",
   "Import Data": "データインポート",
   "Basic Settings": "基本設定",
-  "Basic authentication": "Basic認証",
-  "Guest users access": "ゲストユーザーのアクセス",
   "Register limitation": "登録の制限",
   "The contents entered here will be shown in the header etc": "ここに入力した内容は、ヘッダー等に表示されます。",
   "Public": "公開",
@@ -267,15 +266,17 @@
 
   "modal_rename": {
     "label": {
-      "Rename page": "ページを移動する",
+      "Move/Rename page": "ページを移動/名前変更する",
       "New page name": "移動先のページ名",
       "Current page name": "現在のページ名",
-      "Move recursively": "再帰的に移動",
+      "Recursively": "再帰的に移動/名前変更",
+      "Do not update metadata": "メタデータを更新しない",
       "Redirect": "リダイレクトする"
     },
     "help": {
       "redirect": "<code>%s</code> にアクセスされた際に自動的に新しいページにジャンプします",
-      "recursive": "<code>%s</code> 配下のページも移動します"
+      "metadata": "最終更新ユーザー、最終更新日を更新せず維持します",
+      "recursive": "<code>%s</code> 配下のページも移動/名前変更します"
     }
   },
 
@@ -433,13 +434,11 @@
    },
 
   "security_setting": {
-    "Basic authentication": "Basic認証",
-    "Guest users access": "ゲストユーザーのアクセス",
+    "Guest Users Access": "ゲストユーザーのアクセス",
+    "Fixed by env var": "環境変数 <code>%s=%s</code> により固定されています。",
     "Register limitation": "登録の制限",
+    "Register limitation desc": "新しいユーザーを登録する方法を制限します.",
     "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
-    "common_authentication": "Basic認証を設定すると、ページ全体に共通の認証がかかります。",
-    "without_encryption": "IDとパスワードは暗号化されずに送信されるのでご注意下さい。",
-    "basic_acl_disable": "Public Wiki の設定のため、Basic認証は利用できません。",
     "users_without_account": "アカウントを持たないユーザーはアクセス不可",
     "example": "例",
     "restrict_emails": "登録可能なメールアドレスを制限することができます。",
@@ -465,8 +464,8 @@
     "clientID": "クライアントID",
     "client_secret": "クライアントシークレット",
     "guest_mode": {
-      "deny": "アカウントを持たないユーザーはアクセス不可",
-      "readonly": "閲覧のみ可"
+      "deny": "拒否 (アカウントを持つユーザーのみ利用可能)",
+      "readonly": "許可 (ゲストユーザーも閲覧のみ可能)"
     },
     "registration_mode": {
       "open": "公開 (だれでも登録可能)",
@@ -651,6 +650,7 @@
 
   "user_management": {
     "target_user": "対象ユーザー",
+    "new_password": "新しいパスワード",
     "invite_users": "新規ユーザーの招待",
     "emails": "メールアドレス (複数行入力で複数人招待可能)",
     "invite_thru_email": "招待をメールで送信",
@@ -670,6 +670,7 @@
     "unset": "未設定",
     "temporary_password": "作成したユーザーは仮パスワードが設定されています。",
     "send_temporary_password": "招待メールを送っていない場合、この画面で必ず仮パスワードをコピーし、招待者へ連絡してください。",
+    "password_reset_message": "対象ユーザーに下記のパスワードを伝え、すぐに新しく別のパスワードを設定するよう伝えてください。",
     "send_new_password": "新規発行したパスワードを、対象ユーザーへ連絡してください。",
     "password_never_seen": "表示されたパスワードはこの画面を閉じると二度と表示できませんのでご注意ください。",
     "reset_password": "パスワードの再発行",
@@ -690,7 +691,8 @@
     "group_example": "例: Group1",
     "created_group": "グループを作成しました",
     "add_user": "グループへのユーザー追加",
-    "deny_create_group": "現在の設定では新規グループの作成はできません。",
+    "deny_create_group": "新規グループの作成はできません。",
+    "is_loading_data": "データを取得中です...",
     "choose_action": "削除するグループの限定公開ページの処理を選択してください",
     "delete_group": "グループの削除",
     "group_name": "グループ名",
@@ -707,7 +709,8 @@
   },
 
   "importer_management": {
-    "import_from": "%s からインポート",
+    "import_from_esa": "esa.ioからインポート",
+    "import_from_qiita": "Qiita:Teamからインポート",
     "esa_settings": {
       "team_name": "チーム名",
       "access_token": "アクセストークン",

+ 1 - 11
src/client/js/app.js → src/client/js/app.jsx

@@ -1,5 +1,3 @@
-/* eslint-disable max-len */
-
 import React from 'react';
 import ReactDOM from 'react-dom';
 import { Provider } from 'unstated';
@@ -106,6 +104,7 @@ let componentMappings = {
   'admin-full-text-search-management': <FullTextSearchManagement />,
 
   'staff-credit': <StaffCredit />,
+  'admin-importer': <Importer />,
 };
 
 // additional definitions if data exists
@@ -211,15 +210,6 @@ if (adminUserGroupPageElem != null) {
   );
 }
 
-const adminImporterElem = document.getElementById('admin-importer');
-if (adminImporterElem != null) {
-  ReactDOM.render(
-    <Importer />,
-    adminImporterElem,
-  );
-}
-
-
 // うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
 $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
   ReactDOM.render(

+ 0 - 0
src/client/js/components/Admin/CustomCssEditor.js → src/client/js/components/Admin/CustomCssEditor.jsx


+ 0 - 0
src/client/js/components/Admin/CustomHeaderEditor.js → src/client/js/components/Admin/CustomHeaderEditor.jsx


+ 0 - 0
src/client/js/components/Admin/CustomScriptEditor.js → src/client/js/components/Admin/CustomScriptEditor.jsx


+ 170 - 3
src/client/js/components/Admin/Importer.jsx

@@ -1,19 +1,186 @@
 import React, { Fragment } from 'react';
+import { withTranslation } from 'react-i18next';
+import PropTypes from 'prop-types';
+
+import AppContainer from '../../services/AppContainer';
+import { createSubscribedElement } from '../UnstatedUtils';
+import { toastSuccess, toastError } from '../../util/apiNotification';
 
 class Importer extends React.Component {
 
   constructor(props) {
-    super();
+    super(props);
+    this.state = {
+      esaTeamName: '',
+      esaAccessToken: '',
+    };
+    this.esaHandleSubmit = this.esaHandleSubmit.bind(this);
+    this.esaHandleSubmitTest = this.esaHandleSubmitTest.bind(this);
+    this.esaHandleSubmitUpdate = this.esaHandleSubmitUpdate.bind(this);
+    this.handleInputValue = this.handleInputValue.bind(this);
+  }
+
+  handleInputValue(event) {
+    this.setState({
+      [event.target.name]: event.target.value,
+    });
+  }
+
+  esaHandleSubmit() {
+    try {
+      const params = {
+        'importer:esa:team_name': this.state.esaTeamName,
+        'importer:esa:access_token': this.state.esaAccessToken,
+      };
+      this.props.appContainer.apiPost('/admin/import/esa', params);
+      toastSuccess('Import posts from esa success.');
+    }
+    catch (error) {
+      toastError(error, 'Error occurred in importing pages from esa.io');
+    }
+  }
+
+  esaHandleSubmitTest() {
+    try {
+      const params = {
+        'importer:esa:team_name': this.state.esaTeamName,
+        'importer:esa:access_token': this.state.esaAccessToken,
+
+      };
+
+      this.props.appContainer.apiPost('/admin/import/testEsaAPI', params);
+      toastSuccess('Test connection to esa success.');
+    }
+    catch (error) {
+      toastError(error, 'Test connection to esa failed.');
+    }
+  }
+
+  esaHandleSubmitUpdate() {
+    try {
+      const params = {
+        'importer:esa:team_name': this.state.esaTeamName,
+        'importer:esa:access_token': this.state.esaAccessToken,
+      };
+      this.props.appContainer.apiPost('/admin/settings/importerEsa', params);
+      toastSuccess('Update');
+    }
+    catch (error) {
+      toastError(error);
+    }
   }
 
   render() {
+    const { esaTeamName, esaAccessToken } = this.state;
+    const { t } = this.props;
     return (
       <Fragment>
-        <h1>連打</h1>
+        <form
+          className="form-horizontal"
+          id="importerSettingFormEsa"
+          role="form"
+        >
+          <fieldset>
+            <legend>{ t('importer_management.import_from_esa') }</legend>
+            <table className="table table-bordered table-mapping">
+              <thead>
+                <tr>
+                  <th width="45%">esa.io</th>
+                  <th width="10%"></th>
+                  <th>GROWI</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr>
+                  <th>{ t('Article') }</th>
+                  <th><i className="icon-arrow-right-circle text-success"></i></th>
+                  <th>{ t('Page') }</th>
+                </tr>
+                <tr>
+                  <th>{ t('Category') }</th>
+                  <th><i className="icon-arrow-right-circle text-success"></i></th>
+                  <th>{ t('Page Path') }</th>
+                </tr>
+                <tr>
+                  <th>{ t('User') }</th>
+                  <th></th>
+                  <th>(TBD)</th>
+                </tr>
+              </tbody>
+            </table>
+
+            <div className="well well-sm mb-0 small">
+              <ul>
+                <li>{ t('importer_management.page_skip') }</li>
+              </ul>
+            </div>
+
+            <div className="form-group">
+              <input type="password" name="dummypass" style={{ display: 'none', top: '-100px', left: '-100px' }} />
+            </div>
+
+            <div className="form-group">
+              <label htmlFor="settingForm[importer:esa:team_name]" className="col-xs-3 control-label">
+                { t('importer_management.esa_settings.team_name') }
+              </label>
+              <div className="col-xs-6">
+                <input className="form-control" type="text" name="esaTeamName" value={esaTeamName} onChange={this.handleInputValue} />
+              </div>
+
+            </div>
+
+            <div className="form-group">
+              <label htmlFor="settingForm[importer:esa:access_token]" className="col-xs-3 control-label">
+                { t('importer_management.esa_settings.access_token') }
+              </label>
+              <div className="col-xs-6">
+                <input className="form-control" type="password" name="esaAccessToken" value={esaAccessToken} onChange={this.handleInputValue} />
+              </div>
+            </div>
+
+            <div className="form-group">
+              <div className="col-xs-offset-3 col-xs-6">
+                <input
+                  id="testConnectionToEsa"
+                  type="button"
+                  className="btn btn-primary btn-esa"
+                  name="Esa"
+                  onClick={this.esaHandleSubmit}
+                  value={t('importer_management.import')}
+                />
+                <input type="button" className="btn btn-secondary" onClick={this.esaHandleSubmitUpdate} value={t('Update')} />
+                <span className="col-xs-offset-1">
+                  <input
+                    name="Esa"
+                    type="button"
+                    id="importFromEsa"
+                    className="btn btn-default btn-esa"
+                    onClick={this.esaHandleSubmitTest}
+                    value={t('importer_management.esa_settings.test_connection')}
+                  />
+                </span>
+
+              </div>
+            </div>
+          </fieldset>
+        </form>
       </Fragment>
+
     );
   }
 
 }
 
-export default Importer;
+/**
+ * Wrapper component for using unstated
+ */
+const ImporterWrapper = (props) => {
+  return createSubscribedElement(Importer, props, [AppContainer]);
+};
+
+Importer.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  t: PropTypes.func.isRequired, // i18next
+};
+
+export default withTranslation()(ImporterWrapper);

+ 56 - 0
src/client/js/components/Admin/Users/GiveAdminForm.jsx

@@ -0,0 +1,56 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+
+class AdminMenuForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+
+    };
+
+    this.handleSubmit = this.handleSubmit.bind(this);
+  }
+
+  // これは将来的にapiにするので。あとボタンにするとデザインがよくなかったので。
+  handleSubmit(event) {
+    $(event.currentTarget).parent().submit();
+  }
+
+  render() {
+    const { t, appContainer, user } = this.props;
+
+    return (
+      <a className="px-4">
+        <form action={`/admin/user/${user._id}/makeAdmin`} method="post">
+          <input type="hidden" name="_csrf" value={appContainer.csrfToken} />
+          <span onClick={this.handleSubmit}>
+            <i className="icon-fw icon-magic-wand"></i>{ t('user_management.give_admin_access') }
+          </span>
+        </form>
+      </a>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const AdminMenuFormWrapper = (props) => {
+  return createSubscribedElement(AdminMenuForm, props, [AppContainer]);
+};
+
+AdminMenuForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  user: PropTypes.object.isRequired,
+};
+
+export default withTranslation()(AdminMenuFormWrapper);

+ 124 - 0
src/client/js/components/Admin/Users/PasswordResetModal.jsx

@@ -0,0 +1,124 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import Modal from 'react-bootstrap/es/Modal';
+
+import toastError from '../../../util/apiNotification';
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+
+class PasswordResetModal extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      temporaryPassword: [],
+      isPasswordResetDone: false,
+    };
+
+    this.returnModalBody = this.returnModalBody.bind(this);
+    this.returnModalFooter = this.returnModalFooter.bind(this);
+    this.resetPassword = this.resetPassword.bind(this);
+  }
+
+  async resetPassword() {
+    const { appContainer, user } = this.props;
+
+    const res = await appContainer.apiPost('/admin/users.resetPassword', { user_id: user._id });
+    if (res.ok) {
+      this.setState({ temporaryPassword: res.newPassword, isPasswordResetDone: true });
+    }
+    else {
+      toastError('Failed to reset password');
+    }
+  }
+
+  returnModalBody() {
+    const { t, user } = this.props;
+    return (
+      this.state.isPasswordResetDone
+        ? (
+          <div>
+            <p className="alert alert-danger">{ t('user_management.password_reset_message') }</p>
+            <p>
+              { t('user_management.target_user') }: <code>{ user.email }</code>
+            </p>
+            <p>
+              { t('user_management.new_password') }: <code>{ this.state.temporaryPassword }</code>
+            </p>
+          </div>
+        )
+        : (
+          <div>
+            <p>
+              { t('user_management.password_never_seen') }<br />
+              <span className="text-danger">{ t('user_management.send_new_password') }</span>
+            </p>
+            <p>
+              { t('user_management.target_user') }: <code>{ user.email }</code>
+            </p>
+            <button type="submit" className="btn btn-primary" onClick={this.resetPassword}>
+              { t('user_management.reset_password')}
+            </button>
+          </div>
+        )
+    );
+  }
+
+  returnModalFooter() {
+    return (
+      this.state.isPasswordResetDone
+        ? (
+          <div>
+            <button type="submit" className="btn btn-primary" onClick={this.props.onHideModal}>OK</button>
+          </div>
+        )
+        : (
+          ''
+        )
+    );
+  }
+
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Modal show={this.props.show} onHide={this.props.onHideModal}>
+        <Modal.Header className="modal-header" closeButton>
+          <Modal.Title>
+            { t('user_management.reset_password') }
+          </Modal.Title>
+        </Modal.Header>
+        <Modal.Body>
+          {this.returnModalBody()}
+        </Modal.Body>
+        <Modal.Footer>
+          {this.returnModalFooter()}
+        </Modal.Footer>
+      </Modal>
+
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const PasswordResetModalWrapper = (props) => {
+  return createSubscribedElement(PasswordResetModal, props, [AppContainer]);
+};
+
+PasswordResetModal.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  user: PropTypes.object.isRequired,
+  show: PropTypes.bool.isRequired,
+  onHideModal: PropTypes.func.isRequired,
+};
+
+export default withTranslation()(PasswordResetModalWrapper);

+ 68 - 0
src/client/js/components/Admin/Users/RemoveAdminForm.jsx

@@ -0,0 +1,68 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+
+class RemoveAdminForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+    };
+
+    this.handleSubmit = this.handleSubmit.bind(this);
+  }
+
+  // これは将来的にapiにするので。あとボタンにするとデザインがよくなかったので。
+  handleSubmit(event) {
+    $(event.currentTarget).parent().submit();
+  }
+
+  render() {
+    const { t, user } = this.props;
+    const me = this.props.appContainer.me;
+
+    return (
+      <Fragment>
+        {user.username !== me
+          ? (
+            <a>
+              <form action={`/admin/user/${user._id}/removeFromAdmin`} method="post">
+                <input type="hidden" />
+                <span onClick={this.handleSubmit}>
+                  <i className="icon-fw icon-user-unfollow mb-2"></i>{ t('user_management.remove_admin_access') }
+                </span>
+              </form>
+            </a>
+          )
+          : (
+            <div className="px-4">
+              <i className="icon-fw icon-user-unfollow mb-2"></i>{ t('user_management.remove_admin_access') }
+              <p className="alert alert-danger">{ t('user_management.cannot_remove') }</p>
+            </div>
+          )
+        }
+      </Fragment>
+    );
+  }
+
+}
+
+/**
+* Wrapper component for using unstated
+*/
+const RemoveAdminFormWrapper = (props) => {
+  return createSubscribedElement(RemoveAdminForm, props, [AppContainer]);
+};
+
+RemoveAdminForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  user: PropTypes.object.isRequired,
+};
+
+export default withTranslation()(RemoveAdminFormWrapper);

+ 72 - 0
src/client/js/components/Admin/Users/StatusActivateForm.jsx

@@ -0,0 +1,72 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+
+class StatusActivateForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+
+    };
+
+    this.handleSubmit = this.handleSubmit.bind(this);
+  }
+
+  // これは将来的にapiにするので。あとボタンにするとデザインがよくなかったので。
+  handleSubmit(event) {
+    $(event.currentTarget).parent().submit();
+  }
+
+  render() {
+    const { t, user, appContainer } = this.props;
+
+    return (
+      <Fragment>
+        {user.status === 1
+          ? (
+            <a>
+              <form action={`/admin/user/${user._id}/activate`} method="post">
+                <input type="hidden" name="_csrf" value={appContainer.csrfToken} />
+                <span onClick={this.handleSubmit}>
+                  <i className="icon-fw icon-user-following"></i> { t('user_management.accept') }
+                </span>
+              </form>
+            </a>
+          )
+          : (
+            <a className="px-4">
+              <form action={`/admin/user/${user._id}/activate`} method="post">
+                <input type="hidden" />
+                <span onClick={this.handleSubmit}>
+                  <i className="icon-fw icon-user-following"></i> { t('user_management.accept') }
+                </span>
+              </form>
+            </a>
+          )
+        }
+      </Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const StatusActivateFormWrapper = (props) => {
+  return createSubscribedElement(StatusActivateForm, props, [AppContainer]);
+};
+
+StatusActivateForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  user: PropTypes.object.isRequired,
+};
+
+export default withTranslation()(StatusActivateFormWrapper);

+ 69 - 0
src/client/js/components/Admin/Users/StatusSuspendedForm.jsx

@@ -0,0 +1,69 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+
+class StatusSuspendedForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+
+    };
+
+    this.handleSubmit = this.handleSubmit.bind(this);
+  }
+
+  // これは将来的にapiにするので。あとボタンにするとデザインがよくなかったので。
+  handleSubmit(event) {
+    $(event.currentTarget).parent().submit();
+  }
+
+  render() {
+    const { t, user } = this.props;
+    const me = this.props.appContainer.me;
+
+    return (
+      <Fragment>
+        {user.username !== me
+          ? (
+            <a>
+              <form action={`/admin/user/${user._id}/suspend`} method="post">
+                <input type="hidden" name="_csrf" value={this.props.appContainer.csrfToken} />
+                <span onClick={this.handleSubmit}>
+                  <i className="icon-fw icon-ban"></i>{ t('user_management.deactivate_account') }
+                </span>
+              </form>
+            </a>
+          )
+          : (
+            <div className="px-4">
+              <i className="icon-fw icon-ban mb-2"></i>{ t('user_management.deactivate_account') }
+              <p className="alert alert-danger">{ t('user_management.your_own') }</p>
+            </div>
+          )
+        }
+      </Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const StatusSuspendedFormWrapper = (props) => {
+  return createSubscribedElement(StatusSuspendedForm, props, [AppContainer]);
+};
+
+StatusSuspendedForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  user: PropTypes.object.isRequired,
+};
+
+export default withTranslation()(StatusSuspendedFormWrapper);

+ 79 - 0
src/client/js/components/Admin/Users/UserMenu.jsx

@@ -0,0 +1,79 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import StatusActivateForm from './StatusActivateForm';
+import StatusSuspendedForm from './StatusSuspendedForm';
+import RemoveUserForm from './UserRemoveForm';
+import RemoveAdminForm from './RemoveAdminForm';
+import GiveAdminForm from './GiveAdminForm';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+
+class UserMenu extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+
+    };
+
+    this.onPasswordResetClicked = this.onPasswordResetClicked.bind(this);
+  }
+
+  onPasswordResetClicked() {
+    this.props.onPasswordResetClicked(this.props.user);
+  }
+
+  render() {
+    const { t, user } = this.props;
+
+    return (
+      <Fragment>
+        <div className="btn-group admin-user-menu">
+          <button type="button" className="btn btn-sm btn-default dropdown-toggle" data-toggle="dropdown">
+            <i className="icon-settings"></i> <span className="caret"></span>
+          </button>
+          <ul className="dropdown-menu" role="menu">
+            <li className="dropdown-header">{ t('user_management.edit_menu') }</li>
+            <li onClick={this.onPasswordResetClicked}>
+              <a>
+                <i className="icon-fw icon-key"></i>{ t('user_management.reset_password') }
+              </a>
+            </li>
+            <li className="divider"></li>
+            <li className="dropdown-header">{ t('status') }</li>
+            <li>
+              {(user.status === 1 || user.status === 3) && <StatusActivateForm user={user} />}
+              {user.status === 2 && <StatusSuspendedForm user={user} />}
+              {(user.status === 1 || user.status === 3 || user.status === 5) && <RemoveUserForm user={user} />}
+            </li>
+            <li className="divider pl-0"></li>
+            <li className="dropdown-header">{ t('user_management.administrator_menu') }</li>
+            <li>
+              {user.status === 2 && user.admin === true && <RemoveAdminForm user={user} />}
+              {user.status === 2 && user.admin === false && <GiveAdminForm user={user} />}
+            </li>
+          </ul>
+        </div>
+      </Fragment>
+    );
+  }
+
+}
+
+const UserMenuWrapper = (props) => {
+  return createSubscribedElement(UserMenu, props, [AppContainer]);
+};
+
+UserMenu.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  user: PropTypes.object.isRequired,
+  onPasswordResetClicked: PropTypes.func.isRequired,
+};
+
+export default withTranslation()(UserMenuWrapper);

+ 56 - 0
src/client/js/components/Admin/Users/UserRemoveForm.jsx

@@ -0,0 +1,56 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+
+class UserRemoveForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+
+    };
+
+    this.handleSubmit = this.handleSubmit.bind(this);
+  }
+
+  // これは将来的にapiにするので。あとボタンにするとデザインがよくなかったので。
+  handleSubmit(event) {
+    $(event.currentTarget).parent().submit();
+  }
+
+  render() {
+    const { t, appContainer, user } = this.props;
+
+    return (
+      <a className="px-4">
+        <form action={`/admin/user/${user._id}/remove`} method="post">
+          <input type="hidden" name="_csrf" value={appContainer.csrfToken} />
+          <span onClick={this.handleSubmit}>
+            <i className="icon-fw icon-fire text-danger"></i> { t('Delete') }
+          </span>
+        </form>
+      </a>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserRemoveFormWrapper = (props) => {
+  return createSubscribedElement(UserRemoveForm, props, [AppContainer]);
+};
+
+UserRemoveForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  user: PropTypes.object.isRequired,
+};
+
+export default withTranslation()(UserRemoveFormWrapper);

+ 80 - 0
src/client/js/components/Admin/Users/UserTable.jsx

@@ -1,12 +1,64 @@
 import React, { Fragment } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
+
+import UserPicture from '../../User/UserPicture';
+import UserMenu from './UserMenu';
 
 import { createSubscribedElement } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
 
 class UserTable extends React.Component {
 
+  constructor(props) {
+    super(props);
+
+    this.state = {
+
+    };
+
+    this.getUserStatusLabel = this.getUserStatusLabel.bind(this);
+  }
+
+  /**
+   * user.statusをみてステータスのラベルを返す
+   * @param {string} userStatus
+   * @return ステータスラベル
+   */
+  getUserStatusLabel(userStatus) {
+    let additionalClassName;
+    let text;
+
+    switch (userStatus) {
+      case 1:
+        additionalClassName = 'label-info';
+        text = 'Approval Pending';
+        break;
+      case 2:
+        additionalClassName = 'label-success';
+        text = 'Active';
+        break;
+      case 3:
+        additionalClassName = 'label-warning';
+        text = 'Suspended';
+        break;
+      case 4:
+        additionalClassName = 'label-danger';
+        text = 'Deleted';
+        break;
+      case 5:
+        additionalClassName = 'label-info';
+        text = 'Invited';
+        break;
+    }
+
+    return (
+      <span className={`label ${additionalClassName}`}>
+        {text}
+      </span>
+    );
+  }
 
   render() {
     const { t } = this.props;
@@ -28,6 +80,31 @@ class UserTable extends React.Component {
               <th width="70px"></th>
             </tr>
           </thead>
+          <tbody>
+            {this.props.users.map((user) => {
+              return (
+                <tr key={user._id}>
+                  <td>
+                    <UserPicture user={user} className="picture img-circle" />
+                    {user.admin && <span className="label label-inverse label-admin ml-2">{ t('administrator') }</span>}
+                  </td>
+                  <td>{this.getUserStatusLabel(user.status)}</td>
+                  <td>
+                    <strong>{user.username}</strong>
+                  </td>
+                  <td>{user.name}</td>
+                  <td>{user.email}</td>
+                  <td>{dateFnsFormat(new Date(user.createdAt), 'YYYY-MM-DD')}</td>
+                  <td>
+                    { user.lastLoginAt && <span>{dateFnsFormat(new Date(user.lastLoginAt), 'YYYY-MM-DD HH:mm')}</span> }
+                  </td>
+                  <td>
+                    <UserMenu user={user} onPasswordResetClicked={this.props.onPasswordResetClicked} />
+                  </td>
+                </tr>
+              );
+            })}
+          </tbody>
         </table>
       </Fragment>
     );
@@ -42,6 +119,9 @@ const UserTableWrapper = (props) => {
 UserTable.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  users: PropTypes.array.isRequired,
+  onPasswordResetClicked: PropTypes.func.isRequired,
 };
 
 export default withTranslation()(UserTableWrapper);

+ 59 - 2
src/client/js/components/Admin/Users/Users.jsx

@@ -2,24 +2,70 @@ import React, { Fragment } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
+import PasswordResetModal from './PasswordResetModal';
+import PaginationWrapper from '../../PaginationWrapper';
 import InviteUserControl from './InviteUserControl';
 import UserTable from './UserTable';
 
-import AppContainer from '../../../services/AppContainer';
 import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
 
 class UserPage extends React.Component {
 
   constructor(props) {
     super();
 
+    this.state = {
+      userForPasswordResetModal: null,
+      users: [],
+      activePage: 1,
+      pagingLimit: Infinity,
+      isPasswordResetModalShown: false,
+    };
+
+    this.showPasswordResetModal = this.showPasswordResetModal.bind(this);
+    this.hidePasswordResetModal = this.hidePasswordResetModal.bind(this);
+  }
+
+  // TODO unstatedContainerを作ってそこにリファクタすべき
+  componentDidMount() {
+    const data = document.getElementById('admin-user-page');
+    const users = JSON.parse(data.getAttribute('users'));
+
+    this.setState({
+      users,
+    });
   }
 
+  /**
+   * passwordリセットモーダルが開き、userが渡される
+   * @param {object} user
+   *
+   */
+  showPasswordResetModal(user) {
+    this.setState({
+      isPasswordResetModalShown: true,
+      userForPasswordResetModal: user,
+    });
+  }
+
+  hidePasswordResetModal() {
+    this.setState({ isPasswordResetModalShown: false });
+  }
+
+
   render() {
     const { t } = this.props;
 
     return (
       <Fragment>
+        { this.state.userForPasswordResetModal && (
+          <PasswordResetModal
+            user={this.state.userForPasswordResetModal}
+            show={this.state.isPasswordResetModalShown}
+            onHideModal={this.hidePasswordResetModal}
+          />
+        ) }
         <p>
           <InviteUserControl />
           <a className="btn btn-default btn-outline ml-2" href="/admin/users/external-accounts">
@@ -27,7 +73,17 @@ class UserPage extends React.Component {
             { t('user_management.external_account') }
           </a>
         </p>
-        <UserTable />
+        <UserTable
+          users={this.state.users}
+          onPasswordResetClicked={this.showPasswordResetModal}
+        />
+        <PaginationWrapper
+          activePage={this.state.activePage}
+          changePage={this.handlePage}
+          totalItemsCount={this.state.totalUsers}
+          pagingLimit={this.state.pagingLimit}
+        >
+        </PaginationWrapper>
       </Fragment>
     );
   }
@@ -41,6 +97,7 @@ const UserPageWrapper = (props) => {
 UserPage.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
 };
 
 export default withTranslation()(UserPageWrapper);

+ 0 - 0
src/client/js/components/Page/PagePath.js → src/client/js/components/Page/PagePath.jsx


+ 0 - 0
src/client/js/components/Page/RevisionBody.js → src/client/js/components/Page/RevisionBody.jsx


+ 12 - 18
src/client/js/components/Page/RevisionLoader.jsx

@@ -35,7 +35,7 @@ class RevisionLoader extends React.Component {
     }
   }
 
-  loadData() {
+  async loadData() {
     if (!this.state.isLoaded && !this.state.isLoading) {
       this.setState({ isLoading: true });
     }
@@ -46,23 +46,17 @@ class RevisionLoader extends React.Component {
     };
 
     // load data with REST API
-    this.props.appContainer.apiGet('/revisions.get', requestData)
-      .then((res) => {
-        if (!res.ok) {
-          throw new Error(res.error);
-        }
-
-        this.setState({
-          markdown: res.revision.body,
-          error: null,
-        });
-      })
-      .catch((err) => {
-        this.setState({ error: err });
-      })
-      .finally(() => {
-        this.setState({ isLoaded: true, isLoading: false });
-      });
+    const res = await this.props.appContainer.apiGet('/revisions.get', requestData);
+    this.setState({ isLoaded: true, isLoading: false });
+
+    if (res != null && !res.ok) {
+      throw new Error(res.error);
+    }
+
+    this.setState({
+      markdown: res.revision.body,
+      error: null,
+    });
   }
 
   onWaypointChange(event) {

+ 0 - 0
src/client/js/components/PageAttachment/Attachment.js → src/client/js/components/PageAttachment/Attachment.jsx


+ 0 - 0
src/client/js/components/PageAttachment/DeleteAttachmentModal.js → src/client/js/components/PageAttachment/DeleteAttachmentModal.jsx


+ 0 - 0
src/client/js/components/PageAttachment/PageAttachmentList.js → src/client/js/components/PageAttachment/PageAttachmentList.jsx


+ 3 - 2
src/client/js/components/PageEditor.jsx

@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
 import loggerFactory from '@alias/logger';
 
 import { throttle, debounce } from 'throttle-debounce';
+import { envUtils } from 'growi-commons';
 
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
@@ -161,7 +162,7 @@ class PageEditor extends React.Component {
 
       // when if created newly
       if (res.pageCreated) {
-        logger.info('Page is created', res.pageCreated._id);
+        logger.info('Page is created', res.page._id);
         pageContainer.updateStateAfterSave(res.page);
       }
     }
@@ -321,7 +322,7 @@ class PageEditor extends React.Component {
 
   render() {
     const config = this.props.appContainer.getConfig();
-    const noCdn = !!config.env.NO_CDN;
+    const noCdn = envUtils.toBoolean(config.env.NO_CDN);
     const emojiStrategy = this.props.appContainer.getEmojiStrategy();
 
     return (

+ 0 - 0
src/client/js/components/PageEditor/MarkdownTableUtil.jsx → src/client/js/components/PageEditor/MarkdownTableUtil.js


+ 2 - 2
src/client/js/components/PageEditor/ScrollSyncHelper.js

@@ -144,7 +144,7 @@ class ScrollSyncHelper {
 
       scrollTo -= this.getParentElementOffset(previewElement);
 
-      previewElement.scroll(0, previewElement.scrollTop + scrollTo);
+      previewElement.scrollTop += scrollTo;
     }
   }
 
@@ -176,7 +176,7 @@ class ScrollSyncHelper {
         return;
       }
 
-      previewElement.scroll(0, scrollTo);
+      previewElement.scrollTop = scrollTo;
     }
   }
 

+ 0 - 0
src/client/js/components/PageList/ListView.js → src/client/js/components/PageList/ListView.jsx


+ 9 - 1
src/client/js/components/PageList/Page.js → src/client/js/components/PageList/Page.jsx

@@ -18,6 +18,8 @@ export default class Page extends React.Component {
       flex: 1,
     };
 
+    const hasChildren = this.props.children != null;
+
     return (
       <li className="page-list-li d-flex align-items-center">
         <UserPicture user={page.lastUpdateUser} />
@@ -25,7 +27,12 @@ export default class Page extends React.Component {
           <PagePath page={page} excludePathString={this.props.excludePathString} />
         </a>
         <PageListMeta page={page} />
-        <div style={styleFlex}></div>
+        { hasChildren && (
+          <React.Fragment>
+            <a style={styleFlex} href={link}>&nbsp;</a>
+            {this.props.children}
+          </React.Fragment>
+        ) }
       </li>
     );
   }
@@ -36,6 +43,7 @@ Page.propTypes = {
   page: PropTypes.object.isRequired,
   linkTo: PropTypes.string,
   excludePathString: PropTypes.string,
+  children: PropTypes.array,
 };
 
 Page.defaultProps = {

+ 0 - 0
src/client/js/components/PageList/PageListMeta.js → src/client/js/components/PageList/PageListMeta.jsx


+ 0 - 0
src/client/js/components/PageList/PagePath.js → src/client/js/components/PageList/PagePath.jsx


+ 1 - 1
src/client/js/components/SavePageControls/GrantSelector.jsx

@@ -277,7 +277,7 @@ class GrantSelector extends React.Component {
     return (
       <React.Fragment>
         { this.renderGrantSelector() }
-        { this.props.disabled && this.renderSelectGroupModal() }
+        { !this.props.disabled && this.renderSelectGroupModal() }
       </React.Fragment>
     );
   }

+ 0 - 0
src/client/js/components/SearchForm.js → src/client/js/components/SearchForm.jsx


+ 0 - 0
src/client/js/components/SearchPage.js → src/client/js/components/SearchPage.jsx


+ 0 - 0
src/client/js/components/SearchPage/DeletePageListModal.js → src/client/js/components/SearchPage/DeletePageListModal.jsx


+ 0 - 0
src/client/js/components/SearchPage/SearchPageForm.js → src/client/js/components/SearchPage/SearchPageForm.jsx


+ 0 - 0
src/client/js/components/SearchPage/SearchResult.js → src/client/js/components/SearchPage/SearchResult.jsx


+ 2 - 2
src/client/js/components/SearchPage/SearchResultList.js → src/client/js/components/SearchPage/SearchResultList.jsx

@@ -16,8 +16,8 @@ class SearchResultList extends React.Component {
   render() {
     const resultList = this.props.pages.map((page) => {
       return (
-        <div id={page._id} key={page._id} className="search-result-page">
-          <h2 className="inline"><a href={page.path}>{page.path}</a></h2>
+        <div id={page._id} key={page._id} className="search-result-page mb-5">
+          <h2><a href={page.path}>{page.path}</a></h2>
           { page.tags.length > 0 && (
             <span><i className="tag-icon icon-tag"></i> {page.tags.join(', ')}</span>
           )}

+ 0 - 0
src/client/js/components/SearchTypeahead.js → src/client/js/components/SearchTypeahead.jsx


+ 3 - 3
src/client/js/components/StaffCredit/StaffCredit.jsx

@@ -1,5 +1,5 @@
 import React from 'react';
-import { HotKeys } from 'react-hotkeys';
+import { GlobalHotKeys } from 'react-hotkeys';
 
 import loggerFactory from '@alias/logger';
 
@@ -112,9 +112,9 @@ export default class StaffCredit extends React.Component {
     const keyMap = { check: ['up', 'down', 'right', 'left', 'b', 'a'] };
     const handlers = { check: (event) => { return this.check(event) } };
     return (
-      <HotKeys focused attach={window} keyMap={keyMap} handlers={handlers}>
+      <GlobalHotKeys keyMap={keyMap} handlers={handlers}>
         {this.renderContributors()}
-      </HotKeys>
+      </GlobalHotKeys>
     );
   }
 

+ 21 - 21
src/client/js/legacy/crowi.js

@@ -1,4 +1,4 @@
-/* eslint no-restricted-globals: ['error', 'locaion'] */
+/* eslint-disable react/jsx-filename-extension */
 
 import React from 'react';
 import ReactDOM from 'react-dom';
@@ -340,7 +340,7 @@ $(() => {
     if (input2 === '') {
       prefix2 = prefix2.slice(0, -1);
     }
-    top.location.href = `${prefix1 + input1 + prefix2 + input2}#edit`;
+    window.location.href = `${prefix1 + input1 + prefix2 + input2}#edit`;
     return false;
   });
 
@@ -352,7 +352,7 @@ $(() => {
     if (name.match(/.+\/$/)) {
       name = name.substr(0, name.length - 1);
     }
-    top.location.href = `${pathUtils.encodePagePath(name)}#edit`;
+    window.location.href = `${pathUtils.encodePagePath(name)}#edit`;
     return false;
   });
 
@@ -387,7 +387,7 @@ $(() => {
         }
         else {
           const page = res.page;
-          top.location.href = `${page.path}?renamed=${pagePath}`;
+          window.location.href = `${page.path}?renamed=${pagePath}`;
         }
       });
 
@@ -424,7 +424,7 @@ $(() => {
       }
       else {
         const page = res.page;
-        top.location.href = `${page.path}?duplicated=${pagePath}`;
+        window.location.href = `${page.path}?duplicated=${pagePath}`;
       }
     });
 
@@ -456,7 +456,7 @@ $(() => {
       }
       else {
         const page = res.page;
-        top.location.href = page.path;
+        window.location.href = page.path;
       }
     });
 
@@ -481,7 +481,7 @@ $(() => {
       }
       else {
         const page = res.page;
-        top.location.href = page.path;
+        window.location.href = page.path;
       }
     });
 
@@ -500,7 +500,7 @@ $(() => {
           $('#delete-errors').addClass('alert-danger');
         }
         else {
-          top.location.href = `${res.path}?unlinked=true`;
+          window.location.href = `${res.path}?unlinked=true`;
         }
       });
 
@@ -527,7 +527,7 @@ $(() => {
     $('#edit').removeClass('active');
     $('body').removeClass('on-edit');
     $('body').removeClass('builtin-editor');
-    location.hash = '#';
+    window.location.hash = '#';
   });
 
   /*
@@ -598,7 +598,7 @@ $(() => {
 
       const editorContainer = appContainer.getContainer('EditorContainer');
       editorContainer.saveDraft(path, template);
-      top.location.href = `${path}#edit`;
+      window.location.href = `${path}#edit`;
     });
 
     if (!isSeen) {
@@ -675,7 +675,7 @@ $(() => {
     $('a[data-toggle="tab"][href="#revision-body"]').on('show.bs.tab', () => {
       // couln't solve https://github.com/weseek/crowi-plus/issues/119 completely -- 2017.07.03 Yuki Takei
       window.location.hash = '#';
-      window.history.replaceState('', '', location.href);
+      window.history.replaceState('', '', window.location.href);
     });
   }
   else {
@@ -689,7 +689,7 @@ $(() => {
       window.history.replaceState('', 'HackMD', '#hackmd');
     });
     $('a[data-toggle="tab"][href="#revision-body"]').on('show.bs.tab', () => {
-      window.history.replaceState('', '', location.href.replace(location.hash, ''));
+      window.history.replaceState('', '', window.location.href.replace(window.location.hash, ''));
     });
     // replace all href="#edit" link behaviors
     $(document).on('click', 'a[href="#edit"]', () => {
@@ -707,8 +707,8 @@ window.addEventListener('load', (e) => {
   const { appContainer } = window;
 
   // hash on page
-  if (location.hash) {
-    if ((location.hash === '#edit' || location.hash === '#edit-form') && $('.tab-pane#edit').length > 0) {
+  if (window.location.hash) {
+    if ((window.location.hash === '#edit' || window.location.hash === '#edit-form') && $('.tab-pane#edit').length > 0) {
       appContainer.setState({ editorMode: 'builtin' });
 
       $('a[data-toggle="tab"][href="#edit"]').tab('show');
@@ -718,14 +718,14 @@ window.addEventListener('load', (e) => {
       // focus
       Crowi.setCaretLineAndFocusToEditor();
     }
-    else if (location.hash === '#hackmd' && $('.tab-pane#hackmd').length > 0) {
+    else if (window.location.hash === '#hackmd' && $('.tab-pane#hackmd').length > 0) {
       appContainer.setState({ editorMode: 'hackmd' });
 
       $('a[data-toggle="tab"][href="#hackmd"]').tab('show');
       $('body').addClass('on-edit');
       $('body').addClass('hackmd');
     }
-    else if (location.hash === '#revision-history' && $('.tab-pane#revision-history').length > 0) {
+    else if (window.location.hash === '#revision-history' && $('.tab-pane#revision-history').length > 0) {
       $('a[data-toggle="tab"][href="#revision-history"]').tab('show');
     }
   }
@@ -767,7 +767,7 @@ window.addEventListener('load', (e) => {
     });
   }
 
-  Crowi.highlightSelectedSection(location.hash);
+  Crowi.highlightSelectedSection(window.location.hash);
   Crowi.modifyScrollTop();
   Crowi.initSlimScrollForRevisionToc();
   Crowi.initAffix();
@@ -780,14 +780,14 @@ window.addEventListener('hashchange', (e) => {
   Crowi.modifyScrollTop();
 
   // hash on page
-  if (location.hash) {
-    if (location.hash === '#edit') {
+  if (window.location.hash) {
+    if (window.location.hash === '#edit') {
       $('a[data-toggle="tab"][href="#edit"]').tab('show');
     }
-    else if (location.hash === '#hackmd') {
+    else if (window.location.hash === '#hackmd') {
       $('a[data-toggle="tab"][href="#hackmd"]').tab('show');
     }
-    else if (location.hash === '#revision-history') {
+    else if (window.location.hash === '#revision-history') {
       $('a[data-toggle="tab"][href="#revision-history"]').tab('show');
     }
   }

+ 4 - 2
src/client/js/models/MarkdownTable.js

@@ -11,6 +11,8 @@ const linePartOfTableRE = /^\|[^\r\n]*|[^\r\n]*\|$|([^|\r\n]+\|[^|\r\n]*)+/; //
 // set up DOMParser
 const domParser = new (window.DOMParser)();
 
+const defaultOptions = { stringLength: stringWidth };
+
 /**
  * markdown table class for markdown-table module
  *   ref. https://github.com/wooorm/markdown-table
@@ -19,7 +21,7 @@ export default class MarkdownTable {
 
   constructor(table, options) {
     this.table = table || [];
-    this.options = options || {};
+    this.options = Object.assign(options || {}, defaultOptions);
 
     this.toString = this.toString.bind(this);
   }
@@ -139,7 +141,7 @@ export default class MarkdownTable {
         contents.push(row);
       }
     }
-    return (new MarkdownTable(contents, { align: aligns, stringLength: stringWidth }));
+    return (new MarkdownTable(contents, { align: aligns }));
   }
 
 }

+ 8 - 5
src/client/styles/scss/_on-edit.scss

@@ -90,7 +90,7 @@ body.on-edit {
 
     position: absolute;
     left: $left-margin;
-    z-index: 1;
+    z-index: 7; // forward than .CodeMirror-vscrollbar
     width: calc(100% - #{$left-margin} - #{$right-margin});
     padding-top: 3px;
     pointer-events: none; // disable pointer-events because it becomes an obstacle
@@ -184,13 +184,16 @@ body.on-edit {
       .autoformat-markdown-table-activated .CodeMirror-cursor {
         &:after {
           position: relative;
-          top: -16px;
-          left: 5px;
+          top: -1.1em;
+          left: 0.3em;
           display: block;
-          width: 14px;
-          height: 14px;
+          width: 1em;
+          height: 1em;
           content: ' ';
+
           background-image: url(/images/icons/editor/table.svg);
+          background-repeat: no-repeat;
+          background-size: 1em;
         }
       }
 

+ 6 - 4
src/client/styles/scss/_search.scss

@@ -148,7 +148,7 @@
       padding-right: 0;
 
       &.affix {
-        top: 58px;
+        top: 64px;
         width: 33%;
         height: 100%;
         padding-right: 5px;
@@ -188,9 +188,8 @@
       margin-top: -48px;
 
       > h2 {
-        display: inline;
         margin-right: 10px;
-        font-size: 20px;
+        font-size: 22px;
         line-height: 1em;
       }
 
@@ -212,7 +211,10 @@
   position: sticky;
   top: 0;
   z-index: 99;
-  padding: 10px 0;
+
+  // for sticky layout
+  padding-top: 15px;
+  margin-bottom: 15px;
 
   .input-group-btn .btn {
     height: 34px;

+ 15 - 12
src/lib/service/cdn-resources-service.js

@@ -3,6 +3,8 @@ const urljoin = require('url-join');
 
 const helpers = require('@commons/util/helpers');
 
+const { envUtils } = require('growi-commons');
+
 const cdnLocalScriptRoot = 'public/js/cdn';
 const cdnLocalScriptWebRoot = '/js/cdn';
 const cdnLocalStyleRoot = 'public/styles/cdn';
@@ -14,7 +16,6 @@ class CdnResourcesService {
   constructor() {
     this.logger = require('@alias/logger')('growi:service:CdnResourcesService');
 
-    this.noCdn = !!process.env.NO_CDN;
     this.loadManifests();
   }
 
@@ -23,6 +24,10 @@ class CdnResourcesService {
     this.logger.debug('manifest data loaded : ', this.cdnManifests);
   }
 
+  noCdn() {
+    return envUtils.toBoolean(process.env.NO_CDN);
+  }
+
   getScriptManifestByName(name) {
     const manifests = this.cdnManifests.js
       .filter((manifest) => { return manifest.name === name });
@@ -72,9 +77,8 @@ class CdnResourcesService {
    * Generate script tag string
    *
    * @param {Object} manifest
-   * @param {boolean} noCdn
    */
-  generateScriptTag(manifest, noCdn) {
+  generateScriptTag(manifest) {
     const attrs = [];
     const args = manifest.args || {};
 
@@ -87,7 +91,7 @@ class CdnResourcesService {
 
     // TODO process integrity
 
-    const url = noCdn
+    const url = this.noCdn()
       ? `${urljoin(cdnLocalScriptWebRoot, manifest.name)}.js`
       : manifest.url;
     return `<script src="${url}" ${attrs.join(' ')}></script>`;
@@ -95,7 +99,7 @@ class CdnResourcesService {
 
   getScriptTagByName(name) {
     const manifest = this.getScriptManifestByName(name);
-    return this.generateScriptTag(manifest, this.noCdn);
+    return this.generateScriptTag(manifest);
   }
 
   getScriptTagsByGroup(group) {
@@ -104,7 +108,7 @@ class CdnResourcesService {
         return manifest.groups != null && manifest.groups.includes(group);
       })
       .map((manifest) => {
-        return this.generateScriptTag(manifest, this.noCdn);
+        return this.generateScriptTag(manifest);
       });
   }
 
@@ -112,9 +116,8 @@ class CdnResourcesService {
    * Generate style tag string
    *
    * @param {Object} manifest
-   * @param {boolean} noCdn
    */
-  generateStyleTag(manifest, noCdn) {
+  generateStyleTag(manifest) {
     const attrs = [];
     const args = manifest.args || {};
 
@@ -127,7 +130,7 @@ class CdnResourcesService {
 
     // TODO process integrity
 
-    const url = noCdn
+    const url = this.noCdn()
       ? `${urljoin(cdnLocalStyleWebRoot, manifest.name)}.css`
       : manifest.url;
 
@@ -136,7 +139,7 @@ class CdnResourcesService {
 
   getStyleTagByName(name) {
     const manifest = this.getStyleManifestByName(name);
-    return this.generateStyleTag(manifest, this.noCdn);
+    return this.generateStyleTag(manifest);
   }
 
   getStyleTagsByGroup(group) {
@@ -145,7 +148,7 @@ class CdnResourcesService {
         return manifest.groups != null && manifest.groups.includes(group);
       })
       .map((manifest) => {
-        return this.generateStyleTag(manifest, this.noCdn);
+        return this.generateStyleTag(manifest);
       });
   }
 
@@ -160,7 +163,7 @@ class CdnResourcesService {
       manifest = Object.assign(manifest, { url: url.toString() });
     }
 
-    return this.generateStyleTag(manifest, this.noCdn);
+    return this.generateStyleTag(manifest);
   }
 
 }

+ 3 - 1
src/lib/service/logger/stream.prod.js

@@ -1,3 +1,5 @@
+const { envUtils } = require('growi-commons');
+
 const isBrowser = typeof window !== 'undefined';
 
 let stream;
@@ -9,7 +11,7 @@ if (isBrowser) {
 }
 // node settings
 else {
-  const isFormat = !(process.env.FORMAT_NODE_LOG === 'false');
+  const isFormat = (process.env.FORMAT_NODE_LOG == null) || envUtils.toBoolean(process.env.FORMAT_NODE_LOG);
 
   if (isFormat) {
     const bunyanFormat = require('bunyan-format');

+ 32 - 0
src/migrations/20190619055421-adjust-page-grant.js

@@ -0,0 +1,32 @@
+require('module-alias/register');
+const logger = require('@alias/logger')('growi:migrate:adjust-page-grant');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+module.exports = {
+
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Page = require('@server/models/page')();
+
+    await Page.bulkWrite([
+      {
+        updateMany:
+         {
+           filter: { grant: null },
+           update: { $set: { grant: Page.GRANT_PUBLIC } },
+         },
+      },
+    ]);
+
+    logger.info('Migration has successfully applied');
+
+  },
+
+  down(db) {
+    // do not rollback
+  },
+};

+ 1 - 1
src/server/form/admin/securityGeneral.js

@@ -5,7 +5,7 @@ const stringToArray = require('../../util/formUtil').stringToArrayFilter;
 const normalizeCRLF = require('../../util/formUtil').normalizeCRLFFilter;
 
 module.exports = form(
-  field('settingForm[security:restrictGuestMode]').required(),
+  field('settingForm[security:restrictGuestMode]'),
   field('settingForm[security:registrationMode]').required(),
   field('settingForm[security:registrationWhiteList]').custom(normalizeCRLF).custom(stringToArray),
   field('settingForm[security:list-policy:hideRestrictedByOwner]').trim().toBooleanStrict(),

+ 10 - 2
src/server/models/GlobalNotificationSetting/index.js

@@ -1,5 +1,6 @@
 const mongoose = require('mongoose');
 const nodePath = require('path');
+const { pathUtils } = require('growi-commons');
 
 /**
  * parent schema for GlobalNotificationSetting model
@@ -10,7 +11,9 @@ const globalNotificationSettingSchema = new mongoose.Schema({
   triggerEvents: { type: [String] },
 });
 
-
+/*
+* e.g. "/a/b/c" => ["/a/b/c", "/a/b", "/a", "/"]
+*/
 const generatePathsOnTree = (path, pathList) => {
   pathList.push(path);
 
@@ -23,11 +26,16 @@ const generatePathsOnTree = (path, pathList) => {
   return generatePathsOnTree(newPath, pathList);
 };
 
+/*
+* e.g. "/a/b/c" => ["/a/b/c", "/a/b", "/a", "/"]
+*/
 const generatePathsToMatch = (originalPath) => {
   const pathList = generatePathsOnTree(originalPath, []);
   return pathList.map((path) => {
+    // except for the original trigger path ("/a/b/c"), append "*" to find all matches
+    // e.g. ["/a/b/c", "/a/b", "/a", "/"] => ["/a/b/c", "/a/b/*", "/a/*", "/*"]
     if (path !== originalPath) {
-      return `${path}/*`;
+      return `${pathUtils.addTrailingSlash(path)}*`;
     }
 
     return path;

+ 1 - 1
src/server/models/config.js

@@ -188,7 +188,7 @@ module.exports = function(crowi) {
         NO_CDN: env.NO_CDN || null,
       },
       recentCreatedLimit: crowi.configManager.getConfig('crowi', 'customize:showRecentCreatedNumber'),
-      isAclEnabled: !crowi.aclService.getIsPublicWikiOnly(),
+      isAclEnabled: crowi.aclService.isAclEnabled(),
       globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
     };
 

+ 5 - 1
src/server/models/page-tag-relation.js

@@ -38,12 +38,15 @@ class PageTagRelation {
   }
 
   static async createTagListWithCount(option) {
+    const Tag = mongoose.model('Tag');
     const opt = option || {};
     const sortOpt = opt.sortOpt || {};
     const offset = opt.offset || 0;
     const limit = opt.limit || 50;
 
+    const existTagIds = await Tag.find().distinct('_id');
     const tags = await this.aggregate()
+      .match({ relatedTag: { $in: existTagIds } })
       .group({ _id: '$relatedTag', count: { $sum: 1 } })
       .sort(sortOpt);
 
@@ -54,7 +57,8 @@ class PageTagRelation {
   }
 
   static async listTagsByPage(pageId) {
-    return this.find({ relatedPage: pageId }).populate('relatedTag').select('-_id relatedTag');
+    const tags = await this.find({ relatedPage: pageId }).populate('relatedTag').select('-_id relatedTag');
+    return tags.filter((tag) => { return tag.relatedTag !== null });
   }
 
   static async listTagNamesByPage(pageId) {

+ 18 - 14
src/server/models/page.js

@@ -809,7 +809,7 @@ module.exports = function(crowi) {
 
     // determine User condition
     const hidePagesRestrictedByOwner = crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner');
-    const hidePagesRestrictedByGroup = crowi.configManager.getConfig('crowi', 'security:list-policy:hidePagesRestrictedByGroupInList');
+    const hidePagesRestrictedByGroup = crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup');
 
     // determine UserGroup condition
     let userGroups = null;
@@ -995,9 +995,8 @@ module.exports = function(crowi) {
     let savedPage = await page.save();
     const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
     const revision = await pushRevision(savedPage, newRevision, user);
-    savedPage = await this.findByPath(revision.path)
-      .populate('revision')
-      .populate('creator');
+    savedPage = await this.findByPath(revision.path);
+    await savedPage.populateDataToShowRevision();
 
     if (socketClientId != null) {
       pageEvent.emit('create', savedPage, user, socketClientId);
@@ -1021,9 +1020,8 @@ module.exports = function(crowi) {
     let savedPage = await pageData.save();
     const newRevision = await Revision.prepareRevision(pageData, body, previousBody, user);
     const revision = await pushRevision(savedPage, newRevision, user);
-    savedPage = await this.findByPath(revision.path)
-      .populate('revision')
-      .populate('creator');
+    savedPage = await this.findByPath(revision.path);
+    await savedPage.populateDataToShowRevision();
 
     if (isSyncRevisionToHackmd) {
       savedPage = await this.syncRevisionToHackmd(savedPage);
@@ -1063,11 +1061,12 @@ module.exports = function(crowi) {
     const newPath = this.getDeletedPageName(pageData.path);
     const isTrashed = checkIfTrashed(pageData.path);
 
+    if (isTrashed) {
+      throw new Error('This method does NOT support deleting trashed pages.');
+    }
+
     const socketClientId = options.socketClientId || null;
     if (this.isDeletableName(pageData.path)) {
-      if (isTrashed) {
-        return this.completelyDeletePage(pageData, user, options);
-      }
 
       pageData.status = STATUS_DELETED;
       const updatedPageData = await this.rename(pageData, newPath, user, { socketClientId, createRedirectPage: true });
@@ -1086,7 +1085,7 @@ module.exports = function(crowi) {
     const isTrashed = checkIfTrashed(targetPage.path);
 
     if (isTrashed) {
-      return this.completelyDeletePageRecursively(targetPage, user, options);
+      throw new Error('This method does NOT supports deleting trashed pages.');
     }
 
     const findOpts = { includeRedirect: true };
@@ -1157,6 +1156,7 @@ module.exports = function(crowi) {
     const Bookmark = crowi.model('Bookmark');
     const Attachment = crowi.model('Attachment');
     const Comment = crowi.model('Comment');
+    const PageTagRelation = crowi.model('PageTagRelation');
     const Revision = crowi.model('Revision');
     const pageId = pageData._id;
     const socketClientId = options.socketClientId || null;
@@ -1166,6 +1166,7 @@ module.exports = function(crowi) {
     await Bookmark.removeBookmarksByPageId(pageId);
     await Attachment.removeAttachmentsByPageId(pageId);
     await Comment.removeCommentsByPageId(pageId);
+    await PageTagRelation.remove({ relatedPage: pageId });
     await Revision.removeRevisionsByPath(pageData.path);
     await this.findByIdAndRemove(pageId);
     await this.removeRedirectOriginPageByPath(pageData.path);
@@ -1228,7 +1229,8 @@ module.exports = function(crowi) {
     const Page = this;
     const Revision = crowi.model('Revision');
     const path = pageData.path;
-    const createRedirectPage = options.createRedirectPage || 0;
+    const createRedirectPage = options.createRedirectPage || false;
+    const updateMetadata = options.updateMetadata || false;
     const socketClientId = options.socketClientId || null;
 
     // sanitize path
@@ -1236,8 +1238,10 @@ module.exports = function(crowi) {
 
     // update Page
     pageData.path = newPagePath;
-    pageData.lastUpdateUser = user;
-    pageData.updatedAt = Date.now();
+    if (updateMetadata) {
+      pageData.lastUpdateUser = user;
+      pageData.updatedAt = Date.now();
+    }
     const updatedPageData = await pageData.save();
 
     // update Rivisions

+ 29 - 48
src/server/models/user.js

@@ -17,7 +17,7 @@ module.exports = function(crowi) {
   const STATUS_SUSPENDED = 3;
   const STATUS_DELETED = 4;
   const STATUS_INVITED = 5;
-  const USER_PUBLIC_FIELDS = '_id image isEmailPublished isGravatarEnabled googleId name username email introduction status lang createdAt admin';
+  const USER_PUBLIC_FIELDS = '_id image isEmailPublished isGravatarEnabled googleId name username email introduction status lang createdAt lastLoginAt admin';
   const IMAGE_POPULATION = { path: 'imageAttachment', select: 'filePathProxied' };
 
   const LANG_EN = 'en';
@@ -65,6 +65,18 @@ module.exports = function(crowi) {
     createdAt: { type: Date, default: Date.now },
     lastLoginAt: { type: Date },
     admin: { type: Boolean, default: 0, index: true },
+  }, {
+    toObject: {
+      transform: (doc, ret, opt) => {
+        // omit password
+        delete ret.password;
+        // omit email
+        if (!doc.isEmailPublished) {
+          delete ret.email;
+        }
+        return ret;
+      },
+    },
   });
   userSchema.plugin(mongoosePaginate);
   userSchema.plugin(uniqueValidator);
@@ -374,24 +386,6 @@ module.exports = function(crowi) {
     return true;
   };
 
-  userSchema.statics.filterToPublicFields = function(user) {
-    debug('User is', typeof user, user);
-    if (typeof user !== 'object' || !user._id) {
-      return user;
-    }
-
-    const filteredUser = {};
-    const fields = USER_PUBLIC_FIELDS.split(' ');
-    for (let i = 0; i < fields.length; i++) {
-      const key = fields[i];
-      if (user[key]) {
-        filteredUser[key] = user[key];
-      }
-    }
-
-    return filteredUser;
-  };
-
   userSchema.statics.findUsers = function(options, callback) {
     const sort = options.sort || { status: 1, createdAt: 1 };
 
@@ -444,17 +438,14 @@ module.exports = function(crowi) {
   };
 
   userSchema.statics.findUsersWithPagination = async function(options) {
-    const sort = options.sort || { status: 1, username: 1, createdAt: 1 };
+    const defaultOptions = {
+      sort: { status: 1, username: 1, createdAt: 1 },
+      page: 1,
+      limit: PAGE_ITEMS,
+    };
+    const mergedOptions = Object.assign(defaultOptions, options);
 
-    // eslint-disable-next-line no-return-await
-    return await this.paginate({ status: { $ne: STATUS_DELETED } }, { page: options.page || 1, limit: options.limit || PAGE_ITEMS }, (err, result) => {
-      if (err) {
-        debug('Error on pagination:', err);
-        throw new Error(err);
-      }
-
-      return result;
-    }, { sortBy: sort });
+    return this.paginate({ status: { $ne: STATUS_DELETED } }, mergedOptions);
   };
 
   userSchema.statics.findUsersByPartOfEmail = function(emailPart, options) {
@@ -607,28 +598,18 @@ module.exports = function(crowi) {
     });
   };
 
-  userSchema.statics.resetPasswordByRandomString = function(id) {
-    const User = this;
+  userSchema.statics.resetPasswordByRandomString = async function(id) {
+    const user = await this.findById(id);
 
-    return new Promise(((resolve, reject) => {
-      User.findById(id, (err, userData) => {
-        if (!userData) {
-          return reject(new Error('User not found'));
-        }
+    if (!user) {
+      throw new Error('User not found');
+    }
 
-        // is updatable check
-        // if (userData.isUp
-        const newPassword = generateRandomTempPassword();
-        userData.setPassword(newPassword);
-        userData.save((err, userData) => {
-          if (err) {
-            return reject(err);
-          }
+    const newPassword = generateRandomTempPassword();
+    user.setPassword(newPassword);
+    await user.save();
 
-          resolve({ user: userData, newPassword });
-        });
-      });
-    }));
+    return newPassword;
   };
 
   userSchema.statics.createUsersByInvitation = function(emailList, toSendEmail, callback) {

+ 31 - 24
src/server/routes/admin.js

@@ -105,7 +105,13 @@ module.exports = function(crowi, app) {
   // app.get('/admin/security'                  , admin.security.index);
   actions.security = {};
   actions.security.index = function(req, res) {
-    return res.render('admin/security');
+    const isWikiModeForced = aclService.isWikiModeForced();
+    const guestModeValue = aclService.getGuestModeValue();
+
+    return res.render('admin/security', {
+      isWikiModeForced,
+      guestModeValue,
+    });
   };
 
   // app.get('/admin/markdown'                  , admin.markdown.index);
@@ -412,7 +418,12 @@ module.exports = function(crowi, app) {
 
     const page = parseInt(req.query.page) || 1;
 
-    const result = await User.findUsersWithPagination({ page });
+    const result = await User.findUsersWithPagination({
+      page,
+      select: User.USER_PUBLIC_FIELDS,
+      populate: User.IMAGE_POPULATION,
+    });
+
     const pager = createPager(result.total, result.limit, result.page, result.pages, MAX_PAGE_LIST);
 
     return res.render('admin/users', {
@@ -571,19 +582,22 @@ module.exports = function(crowi, app) {
   };
 
   // app.post('/_api/admin/users.resetPassword' , admin.api.usersResetPassword);
-  actions.user.resetPassword = function(req, res) {
+  actions.user.resetPassword = async function(req, res) {
     const id = req.body.user_id;
     const User = crowi.model('User');
 
-    User.resetPasswordByRandomString(id)
-      .then((data) => {
-        data.user = User.filterToPublicFields(data.user);
-        return res.json(ApiResponse.success(data));
-      })
-      .catch((err) => {
-        debug('Error on reseting password', err);
-        return res.json(ApiResponse.error('Error'));
-      });
+    try {
+      const newPassword = await User.resetPasswordByRandomString(id);
+
+      const user = await User.findById(id);
+
+      const result = { user: user.toObject(), newPassword };
+      return res.json(ApiResponse.success(result));
+    }
+    catch (err) {
+      debug('Error on reseting password', err);
+      return res.json(ApiResponse.error(err));
+    }
   };
 
   actions.externalAccount = {};
@@ -624,7 +638,7 @@ module.exports = function(crowi, app) {
   actions.userGroup = {};
   actions.userGroup.index = function(req, res) {
     const page = parseInt(req.query.page) || 1;
-    const isAclEnabled = !aclService.getIsPublicWikiOnly();
+    const isAclEnabled = aclService.isAclEnabled();
     const renderVar = {
       userGroups: [],
       userGroupRelations: new Map(),
@@ -856,12 +870,9 @@ module.exports = function(crowi, app) {
     }
 
     const form = req.form.settingForm;
-    if (aclService.getIsPublicWikiOnly()) {
-      const guestMode = form['security:restrictGuestMode'];
-      if (guestMode === 'Deny') {
-        req.form.errors.push('Private Wikiへの設定変更はできません。');
-        return res.json({ status: false, message: req.form.errors.join('\n') });
-      }
+    if (aclService.isWikiModeForced()) {
+      logger.debug('security:restrictGuestMode will not be changed because wiki mode is forced to set');
+      delete form['security:restrictGuestMode'];
     }
 
     try {
@@ -1157,11 +1168,7 @@ module.exports = function(crowi, app) {
    * @param {*} res
    */
   actions.api.importerSettingEsa = async(req, res) => {
-    const form = req.form.settingForm;
-
-    if (!req.form.isValid) {
-      return res.json({ status: false, message: req.form.errors.join('\n') });
-    }
+    const form = req.body;
 
     await configManager.updateConfigsInTheSameNamespace('crowi', form);
     importer.initializeEsaClient(); // let it run in the back aftert res

+ 12 - 7
src/server/routes/page.js

@@ -47,6 +47,14 @@ module.exports = function(crowi, app) {
     if (page.revisionHackmdSynced != null && page.revisionHackmdSynced._id != null) {
       returnObj.revisionHackmdSynced = page.revisionHackmdSynced._id;
     }
+
+    if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
+      returnObj.lastUpdateUser = page.lastUpdateUser.toObject();
+    }
+    if (page.creator != null && page.creator instanceof User) {
+      returnObj.creator = page.creator.toObject();
+    }
+
     return returnObj;
   }
 
@@ -585,8 +593,6 @@ module.exports = function(crowi, app) {
     }
 
     const result = { page: serializeToObj(createdPage), tags: savedTags };
-    result.page.lastUpdateUser = User.filterToPublicFields(createdPage.lastUpdateUser);
-    result.page.creator = User.filterToPublicFields(createdPage.creator);
     res.json(ApiResponse.success(result));
 
     // update scopes for descendants
@@ -674,7 +680,6 @@ module.exports = function(crowi, app) {
     }
 
     const result = { page: serializeToObj(page), tags: savedTags };
-    result.page.lastUpdateUser = User.filterToPublicFields(page.lastUpdateUser);
     res.json(ApiResponse.success(result));
 
     // update scopes for descendants
@@ -1047,11 +1052,11 @@ module.exports = function(crowi, app) {
     const previousRevision = req.body.revision_id || null;
     const newPagePath = pathUtils.normalizePath(req.body.new_path);
     const options = {
-      createRedirectPage: req.body.create_redirect || 0,
-      moveUnderTrees: req.body.move_trees || 0,
+      createRedirectPage: (req.body.create_redirect != null),
+      updateMetadata: (req.body.remain_metadata == null),
       socketClientId: +req.body.socketClientId || undefined,
     };
-    const isRecursively = req.body.recursively || 0;
+    const isRecursively = (req.body.recursively != null);
 
     if (!Page.isCreatableName(newPagePath)) {
       return res.json(ApiResponse.error(`Could not use the path '${newPagePath})'`, 'invalid_path'));
@@ -1124,7 +1129,7 @@ module.exports = function(crowi, app) {
     req.body.body = page.revision.body;
     req.body.grant = page.grant;
     req.body.grantedUsers = page.grantedUsers;
-    req.body.grantedGroup = page.grantedGroup;
+    req.body.grantUserGroupId = page.grantedGroup;
     req.body.pageTags = originTags;
 
     return api.create(req, res);

+ 38 - 12
src/server/service/acl.js

@@ -16,24 +16,50 @@ class AclService {
     };
   }
 
-  getIsPublicWikiOnly() {
-    const publicWikiOnly = process.env.PUBLIC_WIKI_ONLY;
-    return !!publicWikiOnly;
+  /**
+   * @returns Whether Access Control is enabled or not
+   */
+  isAclEnabled() {
+    const wikiMode = this.configManager.getConfig('crowi', 'security:wikiMode');
+    return wikiMode !== 'public';
   }
 
-  getIsGuestAllowedToRead() {
-    // return true if puclic wiki mode
-    if (this.getIsPublicWikiOnly()) {
-      return true;
-    }
+  /**
+   * @returns Whether wiki mode is set
+   */
+  isWikiModeForced() {
+    const wikiMode = this.configManager.getConfig('crowi', 'security:wikiMode');
+    const isPrivateOrPublic = wikiMode === 'private' || wikiMode === 'public';
+
+    return isPrivateOrPublic;
+  }
+
+  /**
+   * @returns Whether guest users are allowed to read public pages
+   */
+  isGuestAllowedToRead() {
+    const wikiMode = this.configManager.getConfig('crowi', 'security:wikiMode');
 
-    // return false if undefined
-    const isRestrictGuestMode = this.configManager.getConfig('crowi', 'security:restrictGuestMode');
-    if (isRestrictGuestMode) {
+    // return false if private wiki mode
+    if (wikiMode === 'private') {
       return false;
     }
+    // return true if public wiki mode
+    if (wikiMode === 'public') {
+      return true;
+    }
+
+    const guestMode = this.configManager.getConfig('crowi', 'security:restrictGuestMode');
+
+    // 'Readonly' => returns true (allow access to guests)
+    // 'Deny', null, undefined, '', ... everything else => returns false (requires login)
+    return guestMode === this.labels.SECURITY_RESTRICT_GUEST_MODE_READONLY;
+  }
 
-    return this.labels.SECURITY_RESTRICT_GUEST_MODE_READONLY === isRestrictGuestMode;
+  getGuestModeValue() {
+    return this.isGuestAllowedToRead()
+      ? this.labels.SECURITY_RESTRICT_GUEST_MODE_READONLY
+      : this.labels.SECURITY_RESTRICT_GUEST_MODE_DENY;
   }
 
   getRestrictGuestModeLabels() {

+ 9 - 1
src/server/service/config-loader.js

@@ -1,9 +1,11 @@
 const debug = require('debug')('growi:service:ConfigLoader');
 
+const { envUtils } = require('growi-commons');
+
 const TYPES = {
   NUMBER:  { parse: (v) => { return parseInt(v, 10) } },
   STRING:  { parse: (v) => { return v } },
-  BOOLEAN: { parse: (v) => { return /^(true|1)$/i.test(v) } },
+  BOOLEAN: { parse: (v) => { return envUtils.toBoolean(v) } },
 };
 
 /**
@@ -134,6 +136,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.NUMBER,
     default: Infinity,
   },
+  FORCE_WIKI_MODE: {
+    ns:      'crowi',
+    key:     'security:wikiMode',
+    type:    TYPES.STRING,
+    default: undefined,
+  },
   SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: {
     ns:      'crowi',
     key:     'security:passport-saml:useOnlyEnvVarsForSomeOptions',

+ 4 - 2
src/server/util/middlewares.js

@@ -192,16 +192,18 @@ module.exports = (crowi) => {
    */
   middlewares.loginRequired = function(isStrictly = true) {
     return function(req, res, next) {
-      const User = crowi.model('User');
 
       // when the route is not strictly restricted
       if (!isStrictly) {
         // when allowed to read
-        if (crowi.aclService.getIsGuestAllowedToRead()) {
+        if (crowi.aclService.isGuestAllowedToRead()) {
+          logger.debug('Allowed to read: ', req.path);
           return next();
         }
       }
 
+      const User = crowi.model('User');
+
       // check the user logged in
       //  make sure that req.user isn't username/email string to login which is set by basic-auth-connect
       if (req.user != null && (req.user instanceof Object) && '_id' in req.user) {

+ 1 - 1
src/server/util/search.js

@@ -555,7 +555,7 @@ SearchClient.prototype.appendCriteriaForQueryString = function(query, queryStrin
 
 SearchClient.prototype.filterPagesByViewer = async function(query, user, userGroups) {
   const showPagesRestrictedByOwner = !this.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner');
-  const showPagesRestrictedByGroup = !this.configManager.getConfig('crowi', 'security:list-policy:hidePagesRestrictedByGroupInList');
+  const showPagesRestrictedByGroup = !this.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup');
 
   query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
 

+ 1 - 1
src/server/util/swigFunctions.js

@@ -70,7 +70,7 @@ module.exports = function(crowi, req, locals) {
   locals.customizeService = customizeService;
 
   locals.noCdn = function() {
-    return !!process.env.NO_CDN;
+    return cdnResourcesService.noCdn();
   };
 
   locals.cdnScriptTag = function(name) {

+ 3 - 3
src/server/views/admin/external-accounts.html

@@ -1,11 +1,11 @@
 {% extends '../layout/admin.html' %}
 
-{% block html_title %}{{ customizeService.generateCustomTitle(t('External Account management')) }}{% endblock %}
+{% block html_title %}{{ customizeService.generateCustomTitle(t('external_account_management')) }}{% endblock %}
 
 {% block content_header %}
 <div class="header-wrap">
   <header id="page-header">
-    <h1 id="admin-title" class="title">{{ t('User_management') }}/{{ t('External Account management') }}</h1>
+    <h1 id="admin-title" class="title">{{ t('User_Management') }} / {{ t('external_account_management') }}</h1>
   </header>
 </div>
 {% endblock %}
@@ -92,7 +92,7 @@
               </span>
               {% endif %}
             </td>
-            <td>{{ account.createdAt|date('Y-m-d', account.createdAt.getTimezoneOffset()) }}</td>
+            <td>{{ account.createdAt|datetz('Y-m-d') }}</td>
             <td>
               <div class="btn-group admin-user-menu">
 

+ 34 - 2
src/server/views/admin/importer.html

@@ -1,4 +1,3 @@
-
 {% extends '../layout/admin.html' %}
 
 {% block html_title %}{{ customizeService.generateCustomTitle(t('Import Data')) }}{% endblock %}
@@ -12,7 +11,40 @@
 {% endblock %}
 
 {% block content_main %}
-<div id ="admin-importer"></div>
+<div class="content-main admin-importer">
+
+  <div class="row">
+    <div class="col-md-3">
+      {% include './widget/menu.html' with {current: 'importer'} %}
+    </div>
+    <div class="col-lg-7 col-md-9">
+
+      <!-- Flash message for success -->
+      {% set smessage = req.flash('successMessage') %}
+      {% if smessage.length %}
+      <div class="alert alert-success">
+        {% for e in smessage %}
+        {{ e }}<br>
+        {% endfor %}
+      </div>
+      {% endif %}
+
+      <!-- Flash message for error -->
+      {% set emessage = req.flash('errorMessage') %}
+      {% if emessage.length %}
+      <div class="alert alert-danger">
+        {% for e in emessage %}
+        {{ e }}<br>
+        {% endfor %}
+      </div>
+      {% endif %}
+
+      <div id="admin-importer"></div>
+
+    </div>
+  </div>
+</div>
+
 {% endblock content_main %}
 
 {% block content_footer %}

+ 12 - 4
src/server/views/admin/security.html

@@ -41,13 +41,21 @@
         <legend class="alert-anchor">{{ t('security_settings') }}</legend>
 
           <div class="form-group">
-            <label for="settingForm[security:restrictGuestMode]" class="col-xs-3 control-label">{{ t('Guest users access') }}</label>
+            <label for="settingForm[security:restrictGuestMode]" class="col-xs-3 control-label">{{ t('security_setting.Guest Users Access') }}</label>
             <div class="col-xs-6">
-              <select class="form-control selectpicker" name="settingForm[security:restrictGuestMode]" value="{{ getConfig('crowi', 'security:restrictGuestMode') }}">
+              {% set selectedValue = guestModeValue %}
+              <select class="form-control selectpicker" {% if isWikiModeForced %}disabled{% endif %}
+                  name="settingForm[security:restrictGuestMode]" value="{{ getConfig('crowi', 'security:restrictGuestMode') }}">
                 {% for modeValue, modeLabel in consts.restrictGuestMode %}
-                <option value="{{ t(modeValue) }}" {% if modeValue == getConfig('crowi', 'security:restrictGuestMode') %}selected{% endif %} >{{ t(modeLabel) }}</option>
+                  <option value="{{ t(modeValue) }}" {% if modeValue == selectedValue %}selected{% endif %}>{{ t(modeLabel) }}</option>
                 {% endfor %}
               </select>
+              {% if isWikiModeForced %}
+              <p class="alert alert-warning mt-2">
+                <i class="icon-exclamation icon-fw"></i><b>FIXED</b><br>
+                {{ t('security_setting.Fixed by env var', 'FORCE_WIKI_MODE', getConfig('crowi', 'security:wikiMode')) }}
+              </p>
+              {% endif %}
             </div>
           </div>
 
@@ -59,7 +67,7 @@
                 <option value="{{ t(modeValue) }}" {% if modeValue == getConfig('crowi', 'security:registrationMode') %}selected{% endif %} >{{ t(modeLabel) }}</option>
                 {% endfor %}
               </select>
-              <p class="help-block small">{{ t('The contents entered here will be shown in the header etc') }}</p>
+              <p class="help-block small">{{ t('security_setting.Register limitation desc') }}</p>
             </div>
           </div>
 

+ 4 - 4
src/server/views/admin/user-group-detail.html

@@ -110,7 +110,7 @@
             <div class="form-group">
               <label class="col-sm-2 control-label">{{ t('Created') }}</label>
               <div class="col-sm-4">
-                <input class="form-control" type="text" disabled value="{{userGroup.createdAt|date('Y-m-d', sRelation.relatedUser.createdAt.getTimezoneOffset()) }}">
+                <input class="form-control" type="text" disabled value="{{userGroup.createdAt|datetz('Y-m-d') }}">
               </div>
             </div>
             <div class="form-group">
@@ -136,7 +136,7 @@
             </th>
             <th>{{ t('Name') }}</th>
             <th width="100px">{{ t('Created') }}</th>
-            <th width="150px">{{ t('Last Login')}}</th>
+            <th width="160px">{{ t('Last Login')}}</th>
             <th width="70px"></th>
           </tr>
         </thead>
@@ -151,9 +151,9 @@
               <strong>{{ sRelation.relatedUser.username }}</strong>
             </td>
             <td>{{ sRelation.relatedUser.name }}</td>
-            <td>{{ sRelation.relatedUser.createdAt|date('Y-m-d', sRelation.relatedUser.createdAt.getTimezoneOffset()) }}</td>
+            <td>{{ sRelation.relatedUser.createdAt|datetz('Y-m-d') }}</td>
             <td>
-              {% if sRelation.relatedUser.lastLoginAt %} {{ sRelation.relatedUser.lastLoginAt|date('Y-m-d H:i', sRelation.relatedUser.createdAt.getTimezoneOffset()) }} {% endif %}
+              {% if sRelation.relatedUser.lastLoginAt %} {{ sRelation.relatedUser.lastLoginAt|datetz('Y-m-d H:i:s') }} {% endif %}
             </td>
             <td>
               <div class="btn-group admin-user-menu">

+ 15 - 0
src/server/views/admin/users.html

@@ -12,12 +12,27 @@
 
 {% block content_main %}
 <div class="content-main">
+    {% set smessage = req.flash('successMessage') %}
+  {% if smessage.length %}
+  <div class="alert alert-success">
+    {{ smessage }}
+  </div>
+  {% endif %}
+
+  {% set emessage = req.flash('errorMessage') %}
+  {% if emessage.length %}
+  <div class="alert alert-danger">
+    {{ emessage }}
+  </div>
+  {% endif %}
+
   <div class="col-md-3">
     {% include './widget/menu.html' with {current: 'user'} %}
   </div>
   <div
   class="col-md-9"
   id ="admin-user-page"
+  users= "{{ users | json }}"
   >
   </div>
 </div>

+ 1 - 1
src/server/views/layout/layout.html

@@ -145,7 +145,7 @@
             <li><a href="/me"><i class="icon-fw icon-wrench"></i>{{ t('User Settings') }}</a></li>
             <li role="separator" class="divider"></li>
             <li><a href="/user/{{ user.username }}#user-draft-list"><i class="icon-fw icon-docs"></i>{{ t('List Drafts') }}</a></li>
-            <li><a href="/user"><i class="icon-fw icon-trash"></i>{{ t('Deleted Pages') }}</a></li>
+            <li><a href="/trash"><i class="icon-fw icon-trash"></i>{{ t('Deleted Pages') }}</a></li>
             <li role="separator" class="divider"></li>
             <li><a href="/logout"><i class="icon-fw icon-power"></i>{{ t('Sign out') }}</a></li>
           </ul>

+ 1 - 1
src/server/views/me/external-accounts.html

@@ -86,7 +86,7 @@
             <td>
               <strong>{{ account.accountId }}</strong>
             </td>
-            <td>{{ account.createdAt|date('Y-m-d', account.createdAt.getTimezoneOffset()) }}</td>
+            <td>{{ account.createdAt|datetz('Y-m-d') }}</td>
             <td class="text-center">
               <button class="btn btn-default btn-sm btn-danger"
                   data-toggle="modal" data-target="#diassociate-external-account" data-provider-type="{{ account.providerType }}" data-account-id="{{ account.accountId }}">

+ 13 - 11
src/server/views/modal/delete.html

@@ -4,7 +4,7 @@
 
       <form role="form" id="delete-page-form" onsubmit="return false;">
 
-        <div class="modal-header {% if page.isDeleted() %}bg-danger{% endif %}">
+        <div class="modal-header {% if page.isDeleted() %}bg-danger{% else %}bg-primary{% endif %}">
           <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
           <div class="modal-title">
             {% if page.isDeleted() %}
@@ -25,19 +25,21 @@
           {% if page.grant != 2 %}
           <div class="checkbox checkbox-warning">
             <input name="recursively" id="cbDeleteRecursively" value="1" type="checkbox" checked>
-            <label for="cbDeleteRecursively">{{ t('modal_delete.delete_recursively') }}</label>
-            <p class="help-block"> {{ t('modal_delete.recursively', page.path) }}
-            </p>
+            <label for="cbDeleteRecursively">
+              {{ t('modal_delete.delete_recursively') }}
+              <p class="help-block mt-0"> {{ t('modal_delete.recursively', page.path) }}</p>
+            </label>
           </div>
           {% endif %}
           {% if not page.isDeleted() %}
           <div class="checkbox checkbox-danger">
-          <input name="completely" id="cbDeleteCompletely" {% if !user.canDeleteCompletely(page.creator._id) %} disabled="disabled" {% endif %} value="1"  type="checkbox">
-            <label for="cbDeleteCompletely" class="text-danger">{{ t('modal_delete.delete_completely') }}</label>
+            <input name="completely" id="cbDeleteCompletely" {% if !user.canDeleteCompletely(page.creator._id) %} disabled="disabled" {% endif %} value="1"  type="checkbox">
+            <label for="cbDeleteCompletely" class="text-danger">
+              {{ t('modal_delete.delete_completely') }}
+              <p class="help-block mt-0"> {{ t('modal_delete.completely') }}</p>
+            </label>
             {% if !user.canDeleteCompletely(page.creator._id) %}
-              <p class="bg-danger text-white p-2 mt-2"> <i class="icon-ban" ></i>{{ t('modal_delete.delete_completely_restriction') }}</p>
-            {% else %}
-            <p class="help-block"> {{ t('modal_delete.completely') }}</p>
+              <p class="alert alert-warning p-2 my-0"><i class="icon-ban icon-fw" ></i>{{ t('modal_delete.delete_completely_restriction') }}</p>
             {% endif %}
           </div>
           {% endif %}
@@ -52,12 +54,12 @@
               <input type="hidden" name="revision_id" value="{{ page.revision._id.toString() }}">
               {% if page.isDeleted() %}
                 <input type="hidden" name="completely" value="true">
-                <button type="submit" class="m-l-10 btn btn-sm btn-danger delete-button">
+                <button type="submit" class="m-l-10 btn btn-danger delete-button">
                   <i class="icon-fire" aria-hidden="true"></i>
                   {{ t('delete_completely') }}
                 </button>
               {% else %}
-                <button type="submit" class="m-l-10 btn btn-sm btn-default delete-button">
+                <button type="submit" class="m-l-10 btn btn-primary delete-button">
                   <i class="icon-trash" aria-hidden="true"></i>
                   {{ t('Delete') }}
                 </button>

+ 21 - 9
src/server/views/modal/rename.html

@@ -6,7 +6,7 @@
 
         <div class="modal-header bg-primary">
           <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-          <div class="modal-title">{{ t('modal_rename.label.Rename page') }}</div>
+          <div class="modal-title">{{ t('modal_rename.label.Move/Rename page') }}</div>
         </div>
         <div class="modal-body">
           <div class="form-group">
@@ -29,16 +29,28 @@
 
           <div class="checkbox checkbox-warning">
             <input name="recursively" id="cbRenameRecursively" value="1" type="checkbox" checked>
-            <label for="cbRenameRecursively">{{ t('modal_rename.label.Move recursively') }}</label>
-            <p class="help-block"> {{ t('modal_rename.help.recursive', page.path) }}
-            </p>
+            <label for="cbRenameRecursively">
+              {{ t('modal_rename.label.Recursively') }}
+              <p class="help-block mt-0">{{ t('modal_rename.help.recursive', page.path) }}</p>
+            </label>
           </div>
-          <div class="checkbox checkbox-info">
-            <input name="create_redirect" id="cbRenameRedirect" value="1"  type="checkbox">
-              <label for="cbRenameRedirect">{{ t('modal_rename.label.Redirect') }}</label>
-              <p class="help-block"> {{ t('modal_rename.help.redirect', page.path) }}
-              </p>
+
+          <div class="checkbox checkbox-success">
+            <input name="create_redirect" id="cbRenameRedirect" value="1" type="checkbox">
+            <label for="cbRenameRedirect">
+              {{ t('modal_rename.label.Redirect') }}
+              <p class="help-block mt-0">{{ t('modal_rename.help.redirect', page.path) }}</p>
+            </label>
+          </div>
+
+          <div class="checkbox checkbox-inverse">
+            <input name="remain_metadata" id="cbRenameMetadata" value="1" type="checkbox">
+            <label for="cbRenameMetadata">
+              {{ t('modal_rename.label.Do not update metadata') }}
+              <p class="help-block mt-0">{{ t('modal_rename.help.metadata') }}</p>
+            </label>
           </div>
+
         </div>
         <div class="modal-footer">
           <div class="d-flex justify-content-between">

+ 1 - 1
src/server/views/search.html

@@ -14,7 +14,7 @@
 <div class="container-fluid">
 
   <div class="row">
-    <div id="main" class="main m-t-15 col-md-12 search-page">
+    <div id="main" class="main col-md-12 search-page">
       <div class="" id="search-page"></div>
     </div>
   </div>

+ 1 - 1
src/server/views/widget/page_tabs.html

@@ -51,7 +51,7 @@
         <i class="icon-options-vertical"></i>
       </a>
       <ul class="dropdown-menu">
-        <li><a href="#" data-target="#renamePage" data-toggle="modal"><i class="icon-fw icon-action-redo"></i> {{ t('Move') }}</a></li>
+        <li><a href="#" data-target="#renamePage" data-toggle="modal"><i class="icon-fw icon-action-redo"></i> {{ t('Move/Rename') }}</a></li>
         <li><a href="#" data-target="#duplicatePage" data-toggle="modal"><i class="icon-fw icon-docs"></i> {{ t('Duplicate') }}</a></li>
         <li class="divider"></li>
         <li><a href="#" data-target="#create-template" data-toggle="modal"><i class="icon-fw icon-magic-wand"></i> {{ t('template.option_label.create/edit') }}</a></li>

+ 1 - 1
src/server/views/widget/page_tabs_kibela.html

@@ -48,7 +48,7 @@
         <i class="icon-options-vertical"></i>
       </a>
       <ul class="dropdown-menu">
-        <li><a href="#" data-target="#renamePage" data-toggle="modal"><i class="icon-fw icon-action-redo"></i> {{ t('Move') }}</a></li>
+        <li><a href="#" data-target="#renamePage" data-toggle="modal"><i class="icon-fw icon-action-redo"></i> {{ t('Move/Rename') }}</a></li>
         <li><a href="#" data-target="#duplicatePage" data-toggle="modal"><i class="icon-fw icon-docs"></i> {{ t('Duplicate') }}</a></li>
         <li class="divider"></li>
         <li><a href="#" data-target="#create-template" data-toggle="modal"><i class="icon-fw icon-magic-wand"></i> {{ t('template.option_label.create/edit') }}</a></li>

+ 0 - 8
src/test/models/page.test.js

@@ -23,14 +23,6 @@ describe('Page', () => {
     UserGroupRelation = mongoose.model('UserGroupRelation');
     Page = mongoose.model('Page');
 
-    // remove all
-    await Promise.all([
-      Page.remove({}),
-      User.remove({}),
-      UserGroup.remove({}),
-      UserGroupRelation.remove({}),
-    ]);
-
     await User.insertMany([
       { name: 'Anon 0', username: 'anonymous0', email: 'anonymous0@example.com' },
       { name: 'Anon 1', username: 'anonymous1', email: 'anonymous1@example.com' },

+ 22 - 20
src/test/models/user.test.js

@@ -10,40 +10,42 @@ describe('User', () => {
 
   beforeAll(async(done) => {
     crowi = await getInstance();
-    done();
-  });
-
-  beforeEach(async(done) => {
     User = mongoose.model('User');
+
+    await User.create({
+      name: 'Example for User Test',
+      username: 'usertest',
+      email: 'usertest@example.com',
+      password: 'usertestpass',
+      lang: 'en',
+    });
+
     done();
   });
 
   describe('Create and Find.', () => {
     describe('The user', () => {
-      test('should created', (done) => {
-        User.createUserByEmailAndPassword('Aoi Miyazaki', 'aoi', 'aoi@example.com', 'hogefuga11', 'en', (err, userData) => {
+      test('should created with createUserByEmailAndPassword', (done) => {
+        User.createUserByEmailAndPassword('Example2 for User Test', 'usertest2', 'usertest2@example.com', 'usertest2pass', 'en', (err, userData) => {
           expect(err).toBeNull();
           expect(userData).toBeInstanceOf(User);
+          expect(userData.name).toBe('Example2 for User Test');
           done();
         });
       });
 
-      test('should be found by findUserByUsername', (done) => {
-        User.findUserByUsername('aoi')
-          .then((userData) => {
-            expect(userData).toBeInstanceOf(User);
-            done();
-          });
+      test('should be found by findUserByUsername', async() => {
+        const user = await User.findUserByUsername('usertest');
+        expect(user).toBeInstanceOf(User);
+        expect(user.name).toBe('Example for User Test');
       });
 
-      test('should be found by findUsersByPartOfEmail', (done) => {
-        User.findUsersByPartOfEmail('ao', {})
-          .then((userData) => {
-            expect(userData).toBeInstanceOf(Array);
-            expect(userData[0]).toBeInstanceOf(User);
-            expect(userData[0].email).toEqual('aoi@example.com');
-            done();
-          });
+      test('should be found by findUsersByPartOfEmail', async() => {
+        const users = await User.findUsersByPartOfEmail('usert', {});
+        expect(users).toBeInstanceOf(Array);
+        expect(users.length).toBe(2);
+        expect(users[0]).toBeInstanceOf(User);
+        expect(users[1]).toBeInstanceOf(User);
       });
     });
   });

+ 205 - 0
src/test/service/acl.test.js

@@ -0,0 +1,205 @@
+import each from 'jest-each';
+
+const { getInstance } = require('../setup-crowi');
+
+describe('AclService test', () => {
+  let crowi;
+
+  const initialEnv = process.env;
+
+  beforeEach(async(done) => {
+    crowi = await getInstance();
+    process.env = initialEnv;
+    done();
+  });
+
+
+  describe('isAclEnabled()', () => {
+
+    test('to be false when FORCE_WIKI_MODE is undefined', async() => {
+      delete process.env.FORCE_WIKI_MODE;
+
+      // reload
+      await crowi.configManager.loadConfigs();
+
+      const result = crowi.aclService.isAclEnabled();
+
+      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      expect(wikiMode).toBe(undefined);
+      expect(result).toBe(true);
+    });
+
+    test('to be false when FORCE_WIKI_MODE is dummy string', async() => {
+      process.env.FORCE_WIKI_MODE = 'dummy string';
+
+      // reload
+      await crowi.configManager.loadConfigs();
+
+      const result = crowi.aclService.isAclEnabled();
+
+      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      expect(wikiMode).toBe('dummy string');
+      expect(result).toBe(true);
+    });
+
+    test('to be true when FORCE_WIKI_MODE=private', async() => {
+      process.env.FORCE_WIKI_MODE = 'private';
+
+      // reload
+      await crowi.configManager.loadConfigs();
+
+      const result = crowi.aclService.isAclEnabled();
+
+      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      expect(wikiMode).toBe('private');
+      expect(result).toBe(true);
+    });
+
+    test('to be false when FORCE_WIKI_MODE=public', async() => {
+      process.env.FORCE_WIKI_MODE = 'public';
+
+      // reload
+      await crowi.configManager.loadConfigs();
+
+      const result = crowi.aclService.isAclEnabled();
+
+      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      expect(wikiMode).toBe('public');
+      expect(result).toBe(false);
+    });
+
+  });
+
+
+  describe('isWikiModeForced()', () => {
+
+    test('to be false when FORCE_WIKI_MODE is undefined', async() => {
+      delete process.env.FORCE_WIKI_MODE;
+
+      // reload
+      await crowi.configManager.loadConfigs();
+
+      const result = crowi.aclService.isWikiModeForced();
+
+      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      expect(wikiMode).toBe(undefined);
+      expect(result).toBe(false);
+    });
+
+    test('to be false when FORCE_WIKI_MODE is dummy string', async() => {
+      process.env.FORCE_WIKI_MODE = 'dummy string';
+
+      // reload
+      await crowi.configManager.loadConfigs();
+
+      const result = crowi.aclService.isWikiModeForced();
+
+      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      expect(wikiMode).toBe('dummy string');
+      expect(result).toBe(false);
+    });
+
+    test('to be true when FORCE_WIKI_MODE=private', async() => {
+      process.env.FORCE_WIKI_MODE = 'private';
+
+      // reload
+      await crowi.configManager.loadConfigs();
+
+      const result = crowi.aclService.isWikiModeForced();
+
+      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      expect(wikiMode).toBe('private');
+      expect(result).toBe(true);
+    });
+
+    test('to be false when FORCE_WIKI_MODE=public', async() => {
+      process.env.FORCE_WIKI_MODE = 'public';
+
+      // reload
+      await crowi.configManager.loadConfigs();
+
+      const result = crowi.aclService.isWikiModeForced();
+
+      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      expect(wikiMode).toBe('public');
+      expect(result).toBe(true);
+    });
+
+  });
+
+
+  describe('isGuestAllowedToRead()', () => {
+    let getConfigSpy;
+
+    beforeEach(async(done) => {
+      // prepare spy for ConfigManager.getConfig
+      getConfigSpy = jest.spyOn(crowi.configManager, 'getConfig');
+      done();
+    });
+
+    test('to be false when FORCE_WIKI_MODE=private', async() => {
+      process.env.FORCE_WIKI_MODE = 'private';
+
+      // reload
+      await crowi.configManager.loadConfigs();
+
+      const result = crowi.aclService.isGuestAllowedToRead();
+
+      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      expect(wikiMode).toBe('private');
+      expect(getConfigSpy).not.toHaveBeenCalledWith('crowi', 'security:restrictGuestMode');
+      expect(result).toBe(false);
+    });
+
+    test('to be true when FORCE_WIKI_MODE=public', async() => {
+      process.env.FORCE_WIKI_MODE = 'public';
+
+      // reload
+      await crowi.configManager.loadConfigs();
+
+      const result = crowi.aclService.isGuestAllowedToRead();
+
+      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      expect(wikiMode).toBe('public');
+      expect(getConfigSpy).not.toHaveBeenCalledWith('crowi', 'security:restrictGuestMode');
+      expect(result).toBe(true);
+    });
+
+    each`
+    restrictGuestMode   | expected
+      ${undefined}      | ${false}
+      ${'Deny'}         | ${false}
+      ${'Readonly'}     | ${true}
+      ${'Open'}         | ${false}
+      ${'Restricted'}   | ${false}
+      ${'closed'}       | ${false}
+    `
+      .test('to be $expected when FORCE_WIKI_MODE is undefined'
+          + ' and `security:restrictGuestMode` is \'$restrictGuestMode\'', async({ restrictGuestMode, expected }) => {
+
+        // reload
+        await crowi.configManager.loadConfigs();
+
+        // setup mock implementation
+        getConfigSpy.mockImplementation((ns, key) => {
+          if (ns === 'crowi' && key === 'security:restrictGuestMode') {
+            return restrictGuestMode;
+          }
+          if (ns === 'crowi' && key === 'security:wikiMode') {
+            return undefined;
+          }
+          throw new Error('Unexpected behavior.');
+        });
+
+        const result = crowi.aclService.isGuestAllowedToRead();
+
+        expect(getConfigSpy).toHaveBeenCalledTimes(2);
+        expect(getConfigSpy).toHaveBeenCalledWith('crowi', 'security:wikiMode');
+        expect(getConfigSpy).toHaveBeenCalledWith('crowi', 'security:restrictGuestMode');
+        expect(result).toBe(expected);
+      });
+
+  });
+
+
+});

+ 186 - 0
src/test/util/middlewares.test.js

@@ -0,0 +1,186 @@
+/* eslint-disable arrow-body-style */
+
+import each from 'jest-each';
+
+const { getInstance } = require('../setup-crowi');
+
+describe('middlewares.loginRequired', () => {
+  let crowi;
+  let middlewares;
+
+  beforeEach(async(done) => {
+    crowi = await getInstance();
+    middlewares = require('@server/util/middlewares')(crowi, null);
+    done();
+  });
+
+  // test('returns strict middlware when args is undefined', () => {
+  //   const func = middlewares.loginRequired();
+  //   expect(func).toBe(loginRequiredStrict);
+  // });
+
+  describe('not strict mode', () => {
+    // setup req/res/next
+    const req = {
+      originalUrl: 'original url 1',
+      session: {},
+    };
+    const res = {
+      redirect: jest.fn().mockReturnValue('redirect'),
+    };
+    const next = jest.fn().mockReturnValue('next');
+
+    let loginRequired;
+
+    beforeEach(async(done) => {
+      loginRequired = middlewares.loginRequired(false);
+      done();
+    });
+
+    test('pass guest user when aclService.isGuestAllowedToRead() returns true', () => {
+      // prepare spy for AclService.isGuestAllowedToRead
+      const isGuestAllowedToReadSpy = jest.spyOn(crowi.aclService, 'isGuestAllowedToRead')
+        .mockImplementation(() => true);
+
+      const result = loginRequired(req, res, next);
+
+      expect(isGuestAllowedToReadSpy).toHaveBeenCalledTimes(1);
+      expect(next).toHaveBeenCalled();
+      expect(res.redirect).not.toHaveBeenCalled();
+      expect(result).toBe('next');
+    });
+
+    test('redirect to \'/login\' when aclService.isGuestAllowedToRead() returns false', () => {
+      // prepare spy for AclService.isGuestAllowedToRead
+      const isGuestAllowedToReadSpy = jest.spyOn(crowi.aclService, 'isGuestAllowedToRead')
+        .mockImplementation(() => false);
+
+      const result = loginRequired(req, res, next);
+
+      expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
+      expect(next).not.toHaveBeenCalled();
+      expect(res.redirect).toHaveBeenCalledTimes(1);
+      expect(res.redirect).toHaveBeenCalledWith('/login');
+      expect(result).toBe('redirect');
+    });
+
+  });
+
+
+  describe('strict mode', () => {
+    // setup req/res/next
+    const req = {
+      originalUrl: 'original url 1',
+      session: null,
+    };
+    const res = {
+      redirect: jest.fn().mockReturnValue('redirect'),
+      sendStatus: jest.fn().mockReturnValue('sendStatus'),
+    };
+    const next = jest.fn().mockReturnValue('next');
+
+    let loginRequired;
+    let isGuestAllowedToReadSpy;
+
+    beforeEach(async(done) => {
+      loginRequired = middlewares.loginRequired();
+      // reset session object
+      req.session = {};
+      // spy for AclService.isGuestAllowedToRead
+      isGuestAllowedToReadSpy = jest.spyOn(crowi.aclService, 'isGuestAllowedToRead');
+      done();
+    });
+
+    test('send status 403 when \'req.path\' starts with \'_api\'', () => {
+      req.path = '/_api/someapi';
+
+      const result = loginRequired(req, res, next);
+
+      expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+      expect(next).not.toHaveBeenCalled();
+      expect(res.redirect).not.toHaveBeenCalled();
+      expect(res.sendStatus).toHaveBeenCalledTimes(1);
+      expect(res.sendStatus).toHaveBeenCalledWith(403);
+      expect(result).toBe('sendStatus');
+    });
+
+    test('redirect to \'/login\' when the user does not loggedin', () => {
+      req.path = '/path/that/requires/loggedin';
+
+      const result = loginRequired(req, res, next);
+
+      expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+      expect(next).not.toHaveBeenCalled();
+      expect(res.sendStatus).not.toHaveBeenCalled();
+      expect(res.redirect).toHaveBeenCalledTimes(1);
+      expect(res.redirect).toHaveBeenCalledWith('/login');
+      expect(result).toBe('redirect');
+      expect(req.session.jumpTo).toBe('original url 1');
+    });
+
+    test('pass user who logged in', () => {
+      const User = crowi.model('User');
+
+      req.user = {
+        _id: 'user id',
+        status: User.STATUS_ACTIVE,
+      };
+
+      const result = loginRequired(req, res, next);
+
+      expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+      expect(res.sendStatus).not.toHaveBeenCalled();
+      expect(res.redirect).not.toHaveBeenCalled();
+      expect(next).toHaveBeenCalledTimes(1);
+      expect(result).toBe('next');
+      expect(req.session.jumpTo).toBe(undefined);
+    });
+
+    each`
+      userStatus  | expectedPath
+      ${1}        | ${'/login/error/registered'}
+      ${3}        | ${'/login/error/suspended'}
+      ${5}        | ${'/login/invited'}
+    `
+      .test('redirect to \'$expectedPath\''
+        + ' when user.status is \'$userStatus\' ', ({ userStatus, expectedPath }) => {
+
+        req.user = {
+          _id: 'user id',
+          status: userStatus,
+        };
+
+        const result = loginRequired(req, res, next);
+
+        expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+        expect(next).not.toHaveBeenCalled();
+        expect(res.sendStatus).not.toHaveBeenCalled();
+        expect(res.redirect).toHaveBeenCalledTimes(1);
+        expect(res.redirect).toHaveBeenCalledWith(expectedPath);
+        expect(result).toBe('redirect');
+        expect(req.session.jumpTo).toBe(undefined);
+      });
+
+    test('redirect to \'/login\' when user.status is \'STATUS_DELETED\'', () => {
+      const User = crowi.model('User');
+
+      req.path = '/path/that/requires/loggedin';
+      req.user = {
+        _id: 'user id',
+        status: User.STATUS_DELETED,
+      };
+
+      const result = loginRequired(req, res, next);
+
+      expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+      expect(next).not.toHaveBeenCalled();
+      expect(res.sendStatus).not.toHaveBeenCalled();
+      expect(res.redirect).toHaveBeenCalledTimes(1);
+      expect(res.redirect).toHaveBeenCalledWith('/login');
+      expect(result).toBe('redirect');
+      expect(req.session.jumpTo).toBe('original url 1');
+    });
+
+  });
+
+});

+ 54 - 61
yarn.lock

@@ -2943,6 +2943,11 @@ core-js-pure@3.1.4:
   resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.1.4.tgz#5fa17dc77002a169a3566cc48dc774d2e13e3769"
   integrity sha512-uJ4Z7iPNwiu1foygbcZYJsJs1jiXrTTCvxfLDXNhI/I+NHbSIEyr548y4fcsCEyWY0XgfAG/qqaunJ1SThHenA==
 
+core-js@=2.6.9, core-js@^2.6.5:
+  version "2.6.9"
+  resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.9.tgz#6b4b214620c834152e179323727fc19741b084f2"
+  integrity sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A==
+
 core-js@^1.0.0:
   version "1.2.7"
   resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636"
@@ -2956,11 +2961,6 @@ core-js@^2.5.7:
   resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.5.tgz#44bc8d249e7fb2ff5d00e0341a7ffb94fbf67895"
   integrity sha512-klh/kDpwX8hryYL14M9w/xei6vrv6sE8gTHDG7/T/+SEovB/G4ejwcfE/CBzO6Edsu+OETZMZ3wcX/EjUkrl5A==
 
-core-js@^2.6.5, core-js@^2.6.9:
-  version "2.6.9"
-  resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.9.tgz#6b4b214620c834152e179323727fc19741b084f2"
-  integrity sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A==
-
 core-util-is@1.0.2, core-util-is@~1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
@@ -3271,9 +3271,10 @@ cssstyle@^1.0.0:
   dependencies:
     cssom "0.3.x"
 
-csv-to-markdown-table@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/csv-to-markdown-table/-/csv-to-markdown-table-0.5.0.tgz#df7b5fd2d7d433319cec2fc01f3213c945de99f6"
+csv-to-markdown-table@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/csv-to-markdown-table/-/csv-to-markdown-table-1.0.1.tgz#43da1b0c0c483faa10a23921abc5e47a48e0daba"
+  integrity sha512-sw7oHNTBvmvztdDp5ZdIA3FPOy7fVol08hPgdSfVky4D1bcIoKwSiUeB/3G99mSaHnZh7wgCHcT7wAmyiyiaQA==
 
 currently-unhandled@^0.4.1:
   version "0.4.1"
@@ -4343,13 +4344,13 @@ express-session@^1.16.1:
     safe-buffer "5.1.2"
     uid-safe "~2.1.5"
 
-express-validator@^5.3.1:
-  version "5.3.1"
-  resolved "https://registry.yarnpkg.com/express-validator/-/express-validator-5.3.1.tgz#6f42c6d52554441b0360c40ccfb555b1770affe2"
-  integrity sha512-g8xkipBF6VxHbO1+ksC7nxUU7+pWif0+OZXjZTybKJ/V0aTVhuCoHbyhIPgSYVldwQLocGExPtB2pE0DqK4jsw==
+express-validator@^6.1.1:
+  version "6.1.1"
+  resolved "https://registry.yarnpkg.com/express-validator/-/express-validator-6.1.1.tgz#2ac81c3a11ce670da6f85a39af6f726a587ee2e7"
+  integrity sha512-AF6YOhdDiCU7tUOO/OHp2W++I3qpYX7EInMmEEcRGOjs+qoubwgc5s6Wo3OQgxwsWRGCxXlrF73SIDEmY4y3wg==
   dependencies:
-    lodash "^4.17.10"
-    validator "^10.4.0"
+    lodash "^4.17.11"
+    validator "^11.0.0"
 
 express-webpack-assets@^0.1.0:
   version "0.1.0"
@@ -5137,10 +5138,10 @@ graceful-fs@^4.1.15:
   version "4.1.15"
   resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00"
 
-growi-commons@^4.0.1:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/growi-commons/-/growi-commons-4.0.1.tgz#e0e71c9c286f493e11c0703c809385bcdc6a97a9"
-  integrity sha512-haH4Av1WuQIHic4Jv2RRwDprbKecRKF/3C0wVk9ssBzWtB3V6Oghj5gksajDpYOd7tOKdvkVEqqkFfIV4JQUyQ==
+growi-commons@^4.0.3:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/growi-commons/-/growi-commons-4.0.3.tgz#aa8cec9a45854ff5a66d28bdf3b232adc64e0270"
+  integrity sha512-ktf6wdAOykVkrGCMWBArP+jHjZTg8iDFrnPGNNVoCxm1fnWfRVXBNu7a8mFIvB2wQScSvnoHs2RFBKN/GcJJoA==
 
 growly@^1.3.0:
   version "1.3.0"
@@ -7050,12 +7051,7 @@ lodash.has@^4.0, lodash.has@^4.5.2:
   version "4.5.2"
   resolved "https://registry.yarnpkg.com/lodash.has/-/lodash.has-4.5.2.tgz#d19f4dc1095058cccbe2b0cdf4ee0fe4aa37c862"
 
-lodash.isboolean@^3.0.3:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
-  integrity sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=
-
-lodash.isequal@^4.0.0, lodash.isequal@^4.5.0:
+lodash.isequal@^4.0.0:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
   integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA=
@@ -7064,11 +7060,6 @@ lodash.isfinite@^3.3.2:
   version "3.3.2"
   resolved "https://registry.yarnpkg.com/lodash.isfinite/-/lodash.isfinite-3.3.2.tgz#fb89b65a9a80281833f0b7478b3a5104f898ebb3"
 
-lodash.isobject@^3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/lodash.isobject/-/lodash.isobject-3.0.2.tgz#3c8fb8d5b5bf4bf90ae06e14f2a530a4ed935e1d"
-  integrity sha1-PI+41bW/S/kK4G4U8qUwpO2TXh0=
-
 lodash.memoize@^4.1.2:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
@@ -7286,15 +7277,16 @@ markdown-it-toc-and-anchor-with-slugid@^1.1.4:
     clone "^2.1.0"
     uslug "^1.0.4"
 
-markdown-it@^8.4.0:
-  version "8.4.0"
-  resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-8.4.0.tgz#e2400881bf171f7018ed1bd9da441dac8af6306d"
+markdown-it@^9.0.1:
+  version "9.0.1"
+  resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-9.0.1.tgz#aafe363c43718720b6575fd10625cde6e4ff2d47"
+  integrity sha512-XC9dMBHg28Xi7y5dPuLjM61upIGPJG8AiHNHYqIaXER2KNnn7eKnM5/sF0ImNnyoV224Ogn9b1Pck8VH4k0bxw==
   dependencies:
     argparse "^1.0.7"
     entities "~1.1.1"
     linkify-it "^2.0.0"
     mdurl "^1.0.1"
-    uc.micro "^1.0.3"
+    uc.micro "^1.0.5"
 
 markdown-table@^1.1.0:
   version "1.1.2"
@@ -7548,10 +7540,10 @@ mimic-response@^1.0.0:
   resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b"
   integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==
 
-mini-css-extract-plugin@^0.7.0:
-  version "0.7.0"
-  resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.7.0.tgz#5ba8290fbb4179a43dd27cca444ba150bee743a0"
-  integrity sha512-RQIw6+7utTYn8DBGsf/LpRgZCJMpZt+kuawJ/fju0KiOL6nAaTBNmCJwS7HtwSCXfS47gCkmtBFS7HdsquhdxQ==
+mini-css-extract-plugin@^0.8.0:
+  version "0.8.0"
+  resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.8.0.tgz#81d41ec4fe58c713a96ad7c723cdb2d0bd4d70e1"
+  integrity sha512-MNpRGbNA52q6U92i0qbVpQNsgk7LExy41MdAlG84FeytfDOtRIf/mCHdEgG8rpTKOaNKiqUnZdlptF469hxqOw==
   dependencies:
     loader-utils "^1.1.0"
     normalize-url "1.9.1"
@@ -7782,11 +7774,6 @@ morgan@^1.9.0:
     on-finished "~2.3.0"
     on-headers "~1.0.1"
 
-mousetrap@^1.5.2:
-  version "1.6.3"
-  resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.3.tgz#80fee49665fd478bccf072c9d46bdf1bfed3558a"
-  integrity sha512-bd+nzwhhs9ifsUrC2tWaSgm24/oo2c83zaRyZQF06hYA6sANfsXHtnZ19AbbbDXCDzeH5nZBSQ4NvCjgD62tJA==
-
 move-concurrently@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"
@@ -9509,14 +9496,6 @@ prop-types@^15.5.10, prop-types@^15.5.8:
     loose-envify "^1.3.1"
     object-assign "^4.1.1"
 
-prop-types@^15.6.0, prop-types@^15.7.2:
-  version "15.7.2"
-  resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
-  dependencies:
-    loose-envify "^1.4.0"
-    object-assign "^4.1.1"
-    react-is "^16.8.1"
-
 prop-types@^15.6.1:
   version "15.6.1"
   resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.1.tgz#36644453564255ddda391191fb3a125cbdf654ca"
@@ -9525,6 +9504,14 @@ prop-types@^15.6.1:
     loose-envify "^1.3.1"
     object-assign "^4.1.1"
 
+prop-types@^15.7.2:
+  version "15.7.2"
+  resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
+  dependencies:
+    loose-envify "^1.4.0"
+    object-assign "^4.1.1"
+    react-is "^16.8.1"
+
 proxy-addr@~2.0.2:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.2.tgz#6571504f47bb988ec8180253f85dd7e14952bdec"
@@ -9790,16 +9777,12 @@ react-frame-component@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/react-frame-component/-/react-frame-component-4.0.0.tgz#57d51cdb2da3b204cc34577349f9f5bb84a76aac"
 
-react-hotkeys@^1.1.4:
-  version "1.1.4"
-  resolved "https://registry.yarnpkg.com/react-hotkeys/-/react-hotkeys-1.1.4.tgz#a0712aa2e0c03a759fd7885808598497a4dace72"
-  integrity sha1-oHEqouDAOnWf14hYCFmEl6TaznI=
+react-hotkeys@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/react-hotkeys/-/react-hotkeys-2.0.0.tgz#a7719c7340cbba888b0e9184f806a9ec0ac2c53f"
+  integrity sha512-3n3OU8vLX/pfcJrR3xJ1zlww6KS1kEJt0Whxc4FiGV+MJrQ1mYSYI3qS/11d2MJDFm8IhOXMTFQirfu6AVOF6Q==
   dependencies:
-    lodash.isboolean "^3.0.3"
-    lodash.isequal "^4.5.0"
-    lodash.isobject "^3.0.2"
-    mousetrap "^1.5.2"
-    prop-types "^15.6.0"
+    prop-types "^15.6.1"
 
 react-i18next@^10.6.1:
   version "10.6.1"
@@ -11940,10 +11923,15 @@ uberproto@^1.1.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/uberproto/-/uberproto-1.2.0.tgz#61d4eab024f909c4e6ea52be867c4894a4beeb76"
 
-uc.micro@^1.0.1, uc.micro@^1.0.3:
+uc.micro@^1.0.1:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.5.tgz#0c65f15f815aa08b560a61ce8b4db7ffc3f45376"
 
+uc.micro@^1.0.5:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac"
+  integrity sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==
+
 uglify-js@2.6.0:
   version "2.6.0"
   resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.6.0.tgz#25eaa1cc3550e39410ceefafd1cfbb6b6d15f001"
@@ -12273,11 +12261,16 @@ validator@>=11.0.0:
   resolved "https://registry.yarnpkg.com/validator/-/validator-11.0.0.tgz#fb10128bfb1fd14ce4ed36b79fc94289eae70667"
   integrity sha512-+wnGLYqaKV2++nUv60uGzUJyJQwYVOin6pn1tgEiFCeCQO60yeu3Og9/yPccbBX574kxIcEJicogkzx6s6eyag==
 
-validator@^10.0.0, validator@^10.4.0:
+validator@^10.0.0:
   version "10.11.0"
   resolved "https://registry.yarnpkg.com/validator/-/validator-10.11.0.tgz#003108ea6e9a9874d31ccc9e5006856ccd76b228"
   integrity sha512-X/p3UZerAIsbBfN/IwahhYaBbY68EN/UQBWHtsbXGT5bfrH/p4NQzUCG1kF/rtKaNpnJ7jAu6NGTdSNtyNIXMw==
 
+validator@^11.0.0:
+  version "11.1.0"
+  resolved "https://registry.yarnpkg.com/validator/-/validator-11.1.0.tgz#ac18cac42e0aa5902b603d7a5d9b7827e2346ac4"
+  integrity sha512-qiQ5ktdO7CD6C/5/mYV4jku/7qnqzjrxb3C/Q5wR3vGGinHTgJZN/TdFT3ZX4vXhX2R1PXx42fB1cn5W+uJ4lg==
+
 validator@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/validator/-/validator-2.1.0.tgz#63276570def208adcf1c032c1f4e6a17d2bd8d8b"