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

Merge branch 'feat/enhanced-link-edit-modal-for-master-merge' into feat/share-link-preview

akira-s 5 лет назад
Родитель
Сommit
5e9e3ea7f1
62 измененных файлов с 1248 добавлено и 785 удалено
  1. 41 0
      .devcontainer/Dockerfile
  2. 38 0
      .devcontainer/devcontainer.json
  3. 88 0
      .devcontainer/docker-compose.yml
  4. 1 0
      .gitignore
  5. 0 21
      .vscode/extensions.json
  6. 26 1
      CHANGES.md
  7. 5 3
      config/env.dev.js
  8. 2 1
      config/logger/config.dev.js
  9. 6 2
      config/webpack.common.js
  10. 2 3
      package.json
  11. 24 0
      public/images/icons/editor/bold.svg
  12. 24 0
      resource/locales/ja/translation.json
  13. 7 1
      src/client/js/admin.jsx
  14. 22 9
      src/client/js/app.jsx
  15. 3 4
      src/client/js/base.jsx
  16. 5 0
      src/client/js/boot.js
  17. 13 12
      src/client/js/components/Admin/App/AppSetting.jsx
  18. 9 8
      src/client/js/components/LoginForm.jsx
  19. 5 5
      src/client/js/components/Navbar/NavbarToggler.jsx
  20. 6 6
      src/client/js/components/Navbar/PageCreateButton.jsx
  21. 38 24
      src/client/js/components/Navbar/PersonalDropdown.jsx
  22. 4 2
      src/client/js/components/Navbar/SearchTop.jsx
  23. 30 0
      src/client/js/components/NotAvailableForGuest.jsx
  24. 12 5
      src/client/js/components/Page.jsx
  25. 6 4
      src/client/js/components/Page/TagLabels.jsx
  26. 10 7
      src/client/js/components/PageComment/CommentEditor.jsx
  27. 1 1
      src/client/js/components/PageComments.jsx
  28. 9 6
      src/client/js/components/PageCreateModal.jsx
  29. 0 1
      src/client/js/components/PageDuplicateModal.jsx
  30. 3 1
      src/client/js/components/PageEditor/CodeMirrorEditor.jsx
  31. 181 125
      src/client/js/components/PageEditor/LinkEditModal.jsx
  32. 19 0
      src/client/js/components/PageEditor/MarkdownLinkUtil.js
  33. 3 3
      src/client/js/components/PageEditorByHackmd.jsx
  34. 0 2
      src/client/js/components/PagePathAutoComplete.jsx
  35. 9 6
      src/client/js/components/Sidebar.jsx
  36. 1 1
      src/client/js/components/Sidebar/RecentChanges.jsx
  37. 10 5
      src/client/js/legacy/crowi.js
  38. 2 4
      src/client/js/nologin.jsx
  39. 42 193
      src/client/js/services/AppContainer.js
  40. 151 0
      src/client/js/services/NavigationContainer.js
  41. 0 23
      src/client/js/services/NoLoginContainer.js
  42. 7 3
      src/client/js/services/PageContainer.js
  43. 73 0
      src/client/js/util/color-scheme.js
  44. 2 2
      src/client/js/util/reveal/plugins/markdown.js
  45. 2 0
      src/client/styles/scss/_wiki.scss
  46. 6 0
      src/client/styles/scss/style-app.scss
  47. 1 1
      src/lib/util/mongoose-utils.js
  48. 2 30
      src/server/models/user.js
  49. 5 61
      src/server/routes/admin.js
  50. 2 2
      src/server/routes/apiv3/notification-setting.js
  51. 1 4
      src/server/routes/apiv3/statistics.js
  52. 0 4
      src/server/routes/index.js
  53. 7 3
      src/server/routes/installer.js
  54. 12 12
      src/server/routes/login-passport.js
  55. 39 42
      src/server/routes/login.js
  56. 6 0
      src/server/service/config-loader.js
  57. 1 1
      src/server/service/search-delegator/elasticsearch.js
  58. 11 17
      src/server/util/mailer.js
  59. 2 2
      src/server/util/middlewares.js
  60. 4 2
      src/server/views/admin/app.html
  61. 2 0
      src/server/views/layout/layout.html
  62. 205 110
      yarn.lock

+ 41 - 0
.devcontainer/Dockerfile

@@ -0,0 +1,41 @@
+#-------------------------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
+#-------------------------------------------------------------------------------------------------------------
+
+FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-12
+
+# The node image includes a non-root user with sudo access. Use the
+# "remoteUser" property in devcontainer.json to use it. On Linux, update
+# these values to ensure the container user's UID/GID matches your local values.
+# See https://aka.ms/vscode-remote/containers/non-root-user for details.
+ARG USERNAME=node
+ARG USER_UID=1000
+ARG USER_GID=$USER_UID
+
+RUN mkdir -p /workspace/growi/node_modules
+
+# [Optional] Update UID/GID if needed
+RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
+        groupmod --gid $USER_GID $USERNAME \
+        && usermod --uid $USER_UID --gid $USER_GID $USERNAME; \
+    fi
+RUN chown -R $USER_UID:$USER_GID /home/$USERNAME /workspace;
+
+# *************************************************************
+# * Uncomment this section to use RUN instructions to install *
+# * any needed dependencies after executing "apt-get update". *
+# * See https://docs.docker.com/engine/reference/builder/#run *
+# *************************************************************
+# ENV DEBIAN_FRONTEND=noninteractive
+# RUN apt-get update \
+#    && apt-get -y install --no-install-recommends <your-package-list-here> \
+#    #
+#    # Clean up
+#    && apt-get autoremove -y \
+#    && apt-get clean -y \
+#    && rm -rf /var/lib/apt/lists/*
+# ENV DEBIAN_FRONTEND=dialog
+
+# Uncomment to default to non-root user
+# USER $USER_UID

+ 38 - 0
.devcontainer/devcontainer.json

@@ -0,0 +1,38 @@
+// For format details, see https://aka.ms/vscode-remote/devcontainer.json or this file's README at:
+// https://github.com/microsoft/vscode-dev-containers/tree/v0.117.1/containers/javascript-node-12-mongo
+// If you want to run as a non-root user in the container, see .devcontainer/docker-compose.yml.
+{
+	"name": "GROWI-Dev",
+	"dockerComposeFile": "docker-compose.yml",
+	"service": "node",
+	"workspaceFolder": "/workspace/growi",
+
+	// Set *default* container specific settings.json values on container create.
+	"settings": {
+		"terminal.integrated.shell.linux": "/bin/bash"
+	},
+
+	// Add the IDs of extensions you want installed when the container is created.
+	"extensions": [
+		"dbaeumer.vscode-eslint",
+		"eamodio.gitlens",
+		"msjsdiag.debugger-for-chrome",
+		"firefox-devtools.vscode-firefox-debug",
+		"editorconfig.editorconfig",
+		"esbenp.prettier-vscode",
+		"shinnn.stylelint",
+		"hex-ci.stylelint-plus",
+	],
+
+	// Uncomment the next line if you want start specific services in your Docker Compose config.
+	// "runServices": [],
+
+	// Uncomment the line below if you want to keep your containers running after VS Code shuts down.
+	// "shutdownAction": "none",
+
+	// Use 'postCreateCommand' to run commands after the container is created.
+	// "postCreateCommand": "yarn install",
+
+	// Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root.
+	"remoteUser": "node"
+}

+ 88 - 0
.devcontainer/docker-compose.yml

@@ -0,0 +1,88 @@
+#-------------------------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
+#-------------------------------------------------------------------------------------------------------------
+
+version: '3'
+services:
+  node:
+    # Uncomment the next line to use a non-root user for all processes. You can also
+    # simply use the "remoteUser" property in devcontainer.json if you just want VS Code
+    # and its sub-processes (terminals, tasks, debugging) to execute as the user. On Linux,
+    # you may need to update USER_UID and USER_GID in .devcontainer/Dockerfile to match your
+    # user if not 1000. See https://aka.ms/vscode-remote/containers/non-root for details.
+    user: node
+
+    build:
+      context: .
+      dockerfile: Dockerfile
+
+    ports:
+      - 3000:3000
+      - 3001:3001 # for browser-sync
+
+    volumes:
+      - ..:/workspace/growi:cached
+      - /workspace/growi/node_modules
+      - ../../growi-docker-compose:/workspace/growi-docker-compose:cached
+      - ../../node_modules:/workspace/node_modules:cached
+
+
+    # Overrides default command so things don't shut down after the process ends.
+    command: sleep infinity
+
+    links:
+      - mongo
+      - elasticsearch
+      - hackmd
+
+  mongo:
+    image: mongo:3.6
+    restart: unless-stopped
+    ports:
+      - 27017:27017
+    volumes:
+      - /data/db
+
+  # This container requires '../../growi-docker-compose' repository
+  #   cloned from https://github.com/weseek/growi-docker-compose.git
+  elasticsearch:
+    build:
+      context: ../../growi-docker-compose/elasticsearch
+      dockerfile: ./Dockerfile
+    restart: unless-stopped
+    ports:
+      - 9200:9200
+    environment:
+      - bootstrap.memory_lock=true
+      - "ES_JAVA_OPTS=-Xms256m -Xmx256m"
+    ulimits:
+      memlock:
+        soft: -1
+        hard: -1
+    volumes:
+      - /usr/share/elasticsearch/data
+      - ../../growi-docker-compose/elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
+
+  elasticsearch-head:
+    image: tobias74/elasticsearch-head:6
+    restart: unless-stopped
+    ports:
+      - 9100:9100
+
+  # This container requires '../../growi-docker-compose' repository
+  #   cloned from https://github.com/weseek/growi-docker-compose.git
+  hackmd:
+    build:
+      context: ../../growi-docker-compose/hackmd
+    restart: unless-stopped
+    environment:
+      - GROWI_URI=http://localhost:3000
+      # define 'storage' option value
+      # see https://github.com/sequelize/cli/blob/7160d0/src/helpers/config-helper.js#L192
+      - CMD_DB_URL=sqlite://dummyhost/hackmd/sqlite/codimd.db
+      - CMD_CSP_ENABLE=false
+    ports:
+      - 3010:3000
+    volumes:
+      - /files/sqlite

+ 1 - 0
.gitignore

@@ -40,3 +40,4 @@ package-lock.json
 # IDE, dev #
 .idea
 *.orig
+*.code-workspace

+ 0 - 21
.vscode/extensions.json

@@ -1,21 +0,0 @@
-{
-	// See http://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
-	// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
-
-	// List of extensions which should be recommended for users of this workspace.
-	"recommendations": [
-    "msjsdiag.debugger-for-chrome",
-    "hbenl.vscode-firefox-debug",
-    "editorconfig.editorconfig",
-    "dbaeumer.vscode-eslint",
-    "eg2.vscode-npm-script",
-    "christian-kohler.npm-intellisense",
-    "esbenp.prettier-vscode",
-    "shinnn.stylelint",
-    "hex-ci.stylelint-plus",
-	],
-	// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
-	"unwantedRecommendations": [
-    "hookyqr.beautify",
-	]
-}

+ 26 - 1
CHANGES.md

@@ -1,8 +1,33 @@
 # CHANGES
 
+## v4.0.7-RC
+
+* Feature: Set request timeout for Elasticsearch with env var `ELASTICSEARCH_REQUEST_TIMEOUT`
+* Improvement: Apply styles faster on booting client
+* Fix: Styles are not applyed on installer
+* Fix: Remove last-resort `next()`
+* Fix: Enable/disable Notification settings couldn't change when either of the params is undefined
+* Fix: Text overflow
+
+## v4.0.6
+
+* Fix: Avatar images in Recent Changes are not shown
+* Fix: Full screen modal of Handsontable and Draw.io don't work
+* Fix: Shortcut for creating page respond with modifier key wrongly
+    * Introduced by v4.0.5
+
 ## v4.0.5
 
-* 
+* Improvement: Return pre-defined session id when healthcheck
+* Improvement: Refactor caching for profile image
+* Improvement: Layout for global search help on mobile
+* Improvement: Layout for confidential notation
+* Fix: Shortcut for creating page doesn't work
+* Support: Dev in container
+* Support: Upgrade libs
+    * ldapjs
+    * node-sass
+
 
 ## v4.0.4
 

+ 5 - 3
config/env.dev.js

@@ -2,11 +2,13 @@ module.exports = {
   NODE_ENV: 'development',
   FILE_UPLOAD: 'mongodb',
   // MONGO_GRIDFS_TOTAL_LIMIT: 10485760,   // 10MB
-  // MATHJAX: 1,
+  MATHJAX: 1,
   // NO_CDN: true,
-  // REDIS_URI: 'http://localhost:6379',
-  ELASTICSEARCH_URI: 'http://localhost:9200/growi',
+  MONGO_URI: 'mongodb://mongo:27017/growi',
+  // REDIS_URI: 'http://redis:6379',
+  ELASTICSEARCH_URI: 'http://elasticsearch:9200/growi',
   HACKMD_URI: 'http://localhost:3010',
+  HACKMD_URI_FOR_SERVER: 'http://hackmd:3000',
   // DRAWIO_URI: 'http://localhost:8080/?offline=1&https=0',
   PLUGIN_NAMES_TOBE_LOADED: [
     // 'growi-plugin-lsx',

+ 2 - 1
config/logger/config.dev.js

@@ -30,7 +30,8 @@ module.exports = {
   /*
    * configure level for client
    */
-  'growi:app': 'debug',
+  'growi:cli:bootstrap': 'debug',
+  'growi:cli:app': 'debug',
   'growi:services:*': 'debug',
   // 'growi:StaffCredit': 'debug',
   // 'growi:TableOfContents': 'debug',

+ 6 - 2
config/webpack.common.js

@@ -20,6 +20,7 @@ module.exports = (options) => {
   return {
     mode: options.mode,
     entry: Object.assign({
+      'js/boot':                      './src/client/js/boot',
       'js/app':                       './src/client/js/app',
       'js/admin':                     './src/client/js/admin',
       'js/nologin':                   './src/client/js/nologin',
@@ -165,7 +166,10 @@ module.exports = (options) => {
           },
           commons: {
             test: /(src|resource)[\\/].*\.(js|jsx|json)$/,
-            chunks: 'initial',
+            chunks: (chunk) => {
+              // ignore patterns
+              return chunk.name != null && !chunk.name.match(/boot/);
+            },
             name: 'js/commons',
             minChunks: 2,
             minSize: 1,
@@ -175,7 +179,7 @@ module.exports = (options) => {
             test: /node_modules[\\/].*\.(js|jsx|json)$/,
             chunks: (chunk) => {
               // ignore patterns
-              return chunk.name != null && !chunk.name.match(/legacy-presentation|ie11-polyfill|hackmd-/);
+              return chunk.name != null && !chunk.name.match(/boot|legacy-presentation|ie11-polyfill|hackmd-/);
             },
             name: 'js/vendors',
             minSize: 1,

+ 2 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.0.5-RC",
+  "version": "4.0.7-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -73,7 +73,6 @@
     "JSONStream": "^1.3.5",
     "archiver": "^3.1.1",
     "array.prototype.flatmap": "^1.2.2",
-    "async": "^3.0.1",
     "async-canvas-to-blob": "^1.0.3",
     "aws-sdk": "^2.88.0",
     "axios": "^0.19.0",
@@ -211,7 +210,7 @@
     "mini-css-extract-plugin": "^0.9.0",
     "morgan": "^1.9.0",
     "node-dev": "^4.0.0",
-    "node-sass": "^4.12.0",
+    "node-sass": "^4.14.1",
     "normalize-path": "^3.0.0",
     "null-loader": "^3.0.0",
     "on-headers": "^1.0.1",

+ 24 - 0
public/images/icons/editor/bold.svg

@@ -689,5 +689,29 @@
     "Sign in error": "Login error",
     "Registration successful": "Registration successful",
     "Setup": "Setup"
+  },
+  "message": {
+    "successfully_connected": "Successfully Connected!",
+    "fail_to_save_access_token": "Failed to save access_token. Please try again.",
+    "fail_to_fetch_access_token": "Failed to fetch access_token. Please do connect again.",
+    "successfully_disconnected": "Successfully Disconnected!",
+    "strategy_has_not_been_set_up": "{{strategy}} has not been set up",
+    "maximum_number_of_users": "Can not register more than the maximum number of users.",
+    "database_error": "Database Server Error occured",
+    "sign_in_failure": "Sign in failure.",
+    "aws_sttings_required": "AWS settings required to use this function. Please ask the administrator.",
+    "application_already_installed": "Application already installed.",
+    "email_address_could_not_be_used": "This email address could not be used. (Make sure the allowed email address)",
+    "user_id_is_not_available.":"This User ID is not available.",
+    "email_address_is_already_registered":"This email address is already registered.",
+    "can_not_register_maximum_number_of_users":"Can not register more than the maximum number of users.",
+    "failed_to_register":"Failed to register.",
+    "successfully_created":"The user {{username}} is successfully created.",
+    "can_not_activate_maximum_number_of_users":"Can not activate more than the maximum number of users.",
+    "failed_to_activate":"Failed to activate.",
+    "unable_to_use_this_user":"Unable to use this user.",
+    "complete_to_install1":"Complete to Install GROWI ! Please login as admin account.",
+    "complete_to_install2":"Complete to Install GROWI ! Please check each settings on this page first.",
+    "failed_to_create_admin_user":"Failed to create admin user. {{errMessage}}"
   }
 }

+ 24 - 0
resource/locales/ja/translation.json

@@ -681,5 +681,29 @@
     "Sign in error": "ログインエラー",
     "Registration successful": "登録完了",
     "Setup": "セットアップ"
+  },
+  "message": {
+    "successfully_connected": "接続に成功しました!",
+    "fail_to_save_access_token": "アクセストークンの保存に失敗しました、再度お試しください。",
+    "fail_to_fetch_access_token": "アクセストークンの取得に失敗しました、再度お試しください。",
+    "successfully_disconnected": "切断に成功しました!",
+    "strategy_has_not_been_set_up": "{{strategy}} はセットアップされていません。",
+    "maximum_number_of_users": "ユーザー数が上限を超えたためアクティベートできません。",
+    "database_error":"データベースサーバーに問題があります。",
+    "sign_in_failure": "ログインに失敗しました。",
+    "aws_sttings_required": "この機能にはAWS設定が必要です。管理者に訪ねて下さい。",
+    "application_already_installed": "アプリケーションのインストールが完了しました。",
+    "email_address_could_not_be_used":"このメールアドレスは使用できません。(許可されたメールアドレスを確認してください。)",
+    "user_id_is_not_available":"このユーザーIDは使用できません。",
+    "email_address_is_already_registered":"このメールアドレスは既に登録されています。",
+    "can_not_register_maximum_number_of_users":"ユーザー数が上限を超えたため登録できません。",
+    "failed_to_register":"登録に失敗しました。",
+    "successfully_created":"{{username}} が作成されました。",
+    "can_not_activate_maximum_number_of_users":"ユーザーが上限に達したためアクティベートできません。",
+    "failed_to_activate":"アクティベートに失敗しました。",
+    "unable_to_use_this_user":"利用できないユーザーIDです。",
+    "complete_to_install1":"GROWI のインストールが完了しました!管理者アカウントでログインしてください。",
+    "complete_to_install2":"GROWI のインストールが完了しました!はじめに、このページで各種設定を確認してください。",
+    "failed_to_create_admin_user":"管理ユーザーの作成に失敗しました。{{errMessage}}"
   }
 }

+ 7 - 1
src/client/js/admin.jsx

@@ -23,6 +23,8 @@ import ExportArchiveDataPage from './components/Admin/ExportArchiveDataPage';
 import FullTextSearchManagement from './components/Admin/FullTextSearchManagement';
 import AdminNavigation from './components/Admin/Common/AdminNavigation';
 
+import NavigationContainer from './services/NavigationContainer';
+
 import AdminHomeContainer from './services/AdminHomeContainer';
 import AdminCustomizeContainer from './services/AdminCustomizeContainer';
 import AdminUserGroupDetailContainer from './services/AdminUserGroupDetailContainer';
@@ -41,14 +43,17 @@ import AdminGitHubSecurityContainer from './services/AdminGitHubSecurityContaine
 import AdminTwitterSecurityContainer from './services/AdminTwitterSecurityContainer';
 import AdminNotificationContainer from './services/AdminNotificationContainer';
 
-import { appContainer, componentMappings } from './bootstrap';
+import { appContainer, componentMappings } from './base';
 
 const logger = loggerFactory('growi:admin');
 
+appContainer.initContents();
+
 const { i18n } = appContainer;
 const websocketContainer = appContainer.getContainer('WebsocketContainer');
 
 // create unstated container instance
+const navigationContainer = new NavigationContainer(appContainer);
 const adminAppContainer = new AdminAppContainer(appContainer);
 const adminHomeContainer = new AdminHomeContainer(appContainer);
 const adminCustomizeContainer = new AdminCustomizeContainer(appContainer);
@@ -60,6 +65,7 @@ const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer(appConta
 const injectableContainers = [
   appContainer,
   websocketContainer,
+  navigationContainer,
   adminAppContainer,
   adminHomeContainer,
   adminCustomizeContainer,

+ 22 - 9
src/client/js/app.jsx

@@ -32,6 +32,7 @@ import LikerList from './components/User/LikerList';
 import TableOfContents from './components/TableOfContents';
 
 import PersonalSettings from './components/Me/PersonalSettings';
+import NavigationContainer from './services/NavigationContainer';
 import PageContainer from './services/PageContainer';
 import CommentContainer from './services/CommentContainer';
 import EditorContainer from './services/EditorContainer';
@@ -40,21 +41,24 @@ import GrowiSubNavigation from './components/Navbar/GrowiSubNavigation';
 import GrowiSubNavigationForUserPage from './components/Navbar/GrowiSubNavigationForUserPage';
 import PersonalContainer from './services/PersonalContainer';
 
-import { appContainer, componentMappings } from './bootstrap';
+import { appContainer, componentMappings } from './base';
 
-const logger = loggerFactory('growi:app');
+const logger = loggerFactory('growi:cli:app');
+
+appContainer.initContents();
 
 const { i18n } = appContainer;
 const websocketContainer = appContainer.getContainer('WebsocketContainer');
 
 // create unstated container instance
+const navigationContainer = new NavigationContainer(appContainer);
 const pageContainer = new PageContainer(appContainer);
 const commentContainer = new CommentContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
 const tagContainer = new TagContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
 const injectableContainers = [
-  appContainer, websocketContainer, pageContainer, commentContainer, editorContainer, tagContainer, personalContainer,
+  appContainer, websocketContainer, navigationContainer, pageContainer, commentContainer, editorContainer, tagContainer, personalContainer,
 ];
 
 logger.info('unstated containers have been initialized');
@@ -70,11 +74,7 @@ Object.assign(componentMappings, {
   // 'revision-history': <PageHistory pageId={pageId} />,
   'tags-page': <TagsList crowi={appContainer} />,
 
-  'page-editor': <PageEditor />,
-  'page-editor-path-nav': <PagePathNavForEditor />,
-  'page-editor-options-selector': <OptionsSelector crowi={appContainer} />,
   'page-status-alert': <PageStatusAlert />,
-  'save-page-controls': <SavePageControls />,
 
   'trash-page-alert': <TrashPageAlert />,
 
@@ -86,10 +86,9 @@ Object.assign(componentMappings, {
 // additional definitions if data exists
 if (pageContainer.state.pageId != null) {
   Object.assign(componentMappings, {
-    'page-editor-with-hackmd': <PageEditorByHackmd />,
     'page-comments-list': <PageComments />,
-    'page-attachment': <PageAttachment />,
     'page-comment-write': <CommentEditorLazyRenderer />,
+    'page-attachment': <PageAttachment />,
     'page-management': <PageManagement />,
 
     'revision-toc': <TableOfContents />,
@@ -108,6 +107,20 @@ if (pageContainer.state.path != null) {
     'grw-subnav-for-user-page': <GrowiSubNavigationForUserPage />,
   });
 }
+// additional definitions if user is logged in
+if (appContainer.currentUser != null) {
+  Object.assign(componentMappings, {
+    'page-editor': <PageEditor />,
+    'page-editor-path-nav': <PagePathNavForEditor />,
+    'page-editor-options-selector': <OptionsSelector crowi={appContainer} />,
+    'save-page-controls': <SavePageControls />,
+  });
+  if (pageContainer.state.pageId != null) {
+    Object.assign(componentMappings, {
+      'page-editor-with-hackmd': <PageEditorByHackmd />,
+    });
+  }
+}
 
 Object.keys(componentMappings).forEach((key) => {
   const elem = document.getElementById(key);

+ 3 - 4
src/client/js/bootstrap.jsx → src/client/js/base.jsx

@@ -14,7 +14,7 @@ import WebsocketContainer from './services/WebsocketContainer';
 import PageCreateButton from './components/Navbar/PageCreateButton';
 import PageCreateModal from './components/PageCreateModal';
 
-const logger = loggerFactory('growi:app');
+const logger = loggerFactory('growi:cli:app');
 
 if (!window) {
   window = {};
@@ -29,10 +29,9 @@ const appContainer = new AppContainer();
 // eslint-disable-next-line no-unused-vars
 const websocketContainer = new WebsocketContainer(appContainer);
 
-logger.info('unstated containers have been initialized');
+appContainer.initApp();
 
-appContainer.init();
-appContainer.injectToWindow();
+logger.info('AppContainer has been initialized');
 
 /**
  * define components

+ 5 - 0
src/client/js/boot.js

@@ -0,0 +1,5 @@
+import {
+  applyColorScheme,
+} from './util/color-scheme';
+
+applyColorScheme();

+ 13 - 12
src/client/js/components/Admin/App/AppSetting.jsx

@@ -3,6 +3,8 @@ import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
 
+import NotAvailableForGuest from './NotAvailableForGuest';
+
 class Drawio extends React.Component {
 
   constructor(props) {
@@ -23,11 +25,9 @@ class Drawio extends React.Component {
   }
 
   onEdit() {
-    if (window.crowi != null) {
-      window.crowi.launchDrawioModal('page',
-        this.props.rangeLineNumberOfMarkdown.beginLineNumber,
-        this.props.rangeLineNumberOfMarkdown.endLineNumber);
-    }
+    const { appContainer, rangeLineNumberOfMarkdown } = this.props;
+    const { beginLineNumber, endLineNumber } = rangeLineNumberOfMarkdown;
+    appContainer.launchDrawioModal('page', beginLineNumber, endLineNumber);
   }
 
   componentDidMount() {
@@ -53,13 +53,13 @@ class Drawio extends React.Component {
   render() {
     return (
       <div className="editable-with-drawio position-relative">
-        { !this.isPreview
-          && (
-          <button type="button" className="drawio-iframe-trigger position-absolute btn btn-outline-secondary" onClick={this.onEdit}>
-            <i className="icon-note mr-1"></i>{this.props.t('Edit')}
-          </button>
-          )
-        }
+        { !this.isPreview && (
+          <NotAvailableForGuest>
+            <button type="button" className="drawio-iframe-trigger position-absolute btn btn-outline-secondary" onClick={this.onEdit}>
+              <i className="icon-note mr-1"></i>{this.props.t('Edit')}
+            </button>
+          </NotAvailableForGuest>
+        ) }
         <div
           className="drawio"
           style={this.style}
@@ -77,6 +77,7 @@ class Drawio extends React.Component {
 Drawio.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.object.isRequired,
+
   drawioContent: PropTypes.any.isRequired,
   isPreview: PropTypes.bool,
   rangeLineNumberOfMarkdown: PropTypes.object.isRequired,

+ 9 - 8
src/client/js/components/LoginForm.jsx

@@ -4,8 +4,8 @@ import ReactCardFlip from 'react-card-flip';
 
 import { withTranslation } from 'react-i18next';
 
+import AppContainer from '../services/AppContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
-import NoLoginContainer from '../services/NoLoginContainer';
 
 class LoginForm extends React.Component {
 
@@ -35,12 +35,12 @@ class LoginForm extends React.Component {
 
   handleLoginWithExternalAuth(e) {
     const auth = e.currentTarget.id;
-    const csrf = this.props.noLoginContainer.csrfToken;
+    const { csrf } = this.props.appContainer;
     window.location.href = `/passport/${auth}?_csrf=${csrf}`;
   }
 
   renderLocalOrLdapLoginForm() {
-    const { t, noLoginContainer, isLdapStrategySetup } = this.props;
+    const { t, appContainer, isLdapStrategySetup } = this.props;
 
     return (
       <form role="form" action="/login" method="post">
@@ -70,7 +70,7 @@ class LoginForm extends React.Component {
         </div>
 
         <div className="input-group my-4">
-          <input type="hidden" name="_csrf" value={noLoginContainer.csrfToken} />
+          <input type="hidden" name="_csrf" value={appContainer.csrfToken} />
           <button type="submit" id="login" className="btn btn-fill rounded-0 login mx-auto">
             <div className="eff"></div>
             <span className="btn-label">
@@ -147,10 +147,10 @@ class LoginForm extends React.Component {
   renderRegisterForm() {
     const {
       t,
+      appContainer,
       username,
       name,
       email,
-      noLoginContainer,
       registrationMode,
       registrationWhiteList,
     } = this.props;
@@ -220,7 +220,7 @@ class LoginForm extends React.Component {
           </div>
 
           <div className="input-group justify-content-center my-4">
-            <input type="hidden" name="_csrf" value={noLoginContainer.csrfToken} />
+            <input type="hidden" name="_csrf" value={appContainer.csrfToken} />
             <button type="submit" className="btn btn-fill rounded-0" id="register">
               <div className="eff"></div>
               <span className="btn-label">
@@ -293,12 +293,13 @@ class LoginForm extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const LoginFormWrapper = withUnstatedContainers(LoginForm, [NoLoginContainer]);
+const LoginFormWrapper = withUnstatedContainers(LoginForm, [AppContainer]);
 
 LoginForm.propTypes = {
   // i18next
   t: PropTypes.func.isRequired,
-  noLoginContainer: PropTypes.instanceOf(NoLoginContainer).isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   isRegistering: PropTypes.bool,
   username: PropTypes.string,
   name: PropTypes.string,

+ 5 - 5
src/client/js/components/Navbar/NavbarToggler.jsx

@@ -4,14 +4,14 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '../../services/AppContainer';
+import NavigationContainer from '../../services/NavigationContainer';
 
 const NavbarToggler = (props) => {
 
-  const { appContainer } = props;
+  const { navigationContainer } = props;
 
   const clickHandler = () => {
-    appContainer.toggleDrawer();
+    navigationContainer.toggleDrawer();
   };
 
   return (
@@ -31,12 +31,12 @@ const NavbarToggler = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const NavbarTogglerWrapper = withUnstatedContainers(NavbarToggler, [AppContainer]);
+const NavbarTogglerWrapper = withUnstatedContainers(NavbarToggler, [NavigationContainer]);
 
 
 NavbarToggler.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 
 export default withTranslation()(NavbarTogglerWrapper);

+ 6 - 6
src/client/js/components/Navbar/PageCreateButton.jsx

@@ -4,21 +4,21 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '../../services/AppContainer';
+import NavigationContainer from '../../services/NavigationContainer';
 
 const PageCreateButton = (props) => {
-  const { t, appContainer, isIcon } = props;
+  const { t, navigationContainer, isIcon } = props;
 
   if (isIcon) {
     return (
-      <button className="btn btn-lg btn-primary rounded-circle waves-effect waves-light" type="button" onClick={appContainer.openPageCreateModal}>
+      <button className="btn btn-lg btn-primary rounded-circle waves-effect waves-light" type="button" onClick={navigationContainer.openPageCreateModal}>
         <i className="icon-pencil"></i>
       </button>
     );
   }
 
   return (
-    <button className="px-md-2 nav-link create-page border-0 bg-transparent" type="button" onClick={appContainer.openPageCreateModal}>
+    <button className="px-md-2 nav-link create-page border-0 bg-transparent" type="button" onClick={navigationContainer.openPageCreateModal}>
       <i className="icon-pencil mr-2"></i>
       <span className="d-none d-lg-block">{ t('New') }</span>
     </button>
@@ -28,12 +28,12 @@ const PageCreateButton = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const PageCreateButtonWrapper = withUnstatedContainers(PageCreateButton, [AppContainer]);
+const PageCreateButtonWrapper = withUnstatedContainers(PageCreateButton, [NavigationContainer]);
 
 
 PageCreateButton.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 
   isIcon: PropTypes.bool,
 };

+ 38 - 24
src/client/js/components/Navbar/PersonalDropdown.jsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState } from 'react';
 import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
@@ -7,14 +7,27 @@ import { UncontrolledTooltip } from 'reactstrap';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
+import NavigationContainer from '../../services/NavigationContainer';
+
+import {
+  isUserPreferenceExists,
+  isDarkMode as isDarkModeByUtil,
+  applyColorScheme,
+  removeUserPreference,
+  updateUserPreference,
+  updateUserPreferenceWithOsSettings,
+} from '../../util/color-scheme';
 
 import UserPicture from '../User/UserPicture';
 
 const PersonalDropdown = (props) => {
 
-  const { t, appContainer } = props;
+  const { t, appContainer, navigationContainer } = props;
   const user = appContainer.currentUser || {};
 
+  const [useOsSettings, setOsSettings] = useState(!isUserPreferenceExists());
+  const [isDarkMode, setIsDarkMode] = useState(isDarkModeByUtil());
+
   const logoutHandler = () => {
     const { interceptorManager } = appContainer;
 
@@ -28,26 +41,33 @@ const PersonalDropdown = (props) => {
   };
 
   const preferDrawerModeSwitchModifiedHandler = (bool) => {
-    appContainer.setDrawerModePreference(bool);
+    navigationContainer.setDrawerModePreference(bool);
   };
 
   const preferDrawerModeOnEditSwitchModifiedHandler = (bool) => {
-    appContainer.setDrawerModePreferenceOnEdit(bool);
+    navigationContainer.setDrawerModePreferenceOnEdit(bool);
   };
 
   const followOsCheckboxModifiedHandler = (bool) => {
-    // reset user preference
     if (bool) {
-      appContainer.setColorSchemePreference(null);
+      removeUserPreference();
     }
-    // set preferDarkModeByMediaQuery as users preference
     else {
-      appContainer.setColorSchemePreference(appContainer.state.preferDarkModeByMediaQuery);
+      updateUserPreferenceWithOsSettings();
     }
+    applyColorScheme();
+
+    // update states
+    setOsSettings(bool);
+    setIsDarkMode(isDarkModeByUtil());
   };
 
   const userPreferenceSwitchModifiedHandler = (bool) => {
-    appContainer.setColorSchemePreference(bool);
+    updateUserPreference(bool);
+    applyColorScheme();
+
+    // update state
+    setIsDarkMode(isDarkModeByUtil());
   };
 
 
@@ -55,15 +75,8 @@ const PersonalDropdown = (props) => {
    * render
    */
   const {
-    preferDarkModeByMediaQuery, preferDarkModeByUser, preferDrawerModeByUser, preferDrawerModeOnEditByUser,
-  } = appContainer.state;
-  const isUserPreferenceExists = preferDarkModeByUser != null;
-  const isDarkMode = () => {
-    if (isUserPreferenceExists) {
-      return preferDarkModeByUser;
-    }
-    return preferDarkModeByMediaQuery;
-  };
+    preferDrawerModeByUser, preferDrawerModeOnEditByUser,
+  } = navigationContainer.state;
 
   /* eslint-disable react/prop-types */
   const DrawerIcon = props => (
@@ -166,7 +179,7 @@ const PersonalDropdown = (props) => {
                   id="cbFollowOs"
                   className="custom-control-input"
                   type="checkbox"
-                  checked={!isUserPreferenceExists}
+                  checked={useOsSettings}
                   onChange={e => followOsCheckboxModifiedHandler(e.target.checked)}
                 />
                 <label className="custom-control-label text-nowrap" htmlFor="cbFollowOs">{t('personal_dropdown.use_os_settings')}</label>
@@ -175,19 +188,19 @@ const PersonalDropdown = (props) => {
           </div>
           <div className="form-row justify-content-center">
             <div className="form-group col-auto mb-0 d-flex align-items-center">
-              <span className={isUserPreferenceExists ? '' : 'text-muted'}>Light</span>
+              <span className={useOsSettings ? '' : 'text-muted'}>Light</span>
               <div className="custom-control custom-switch custom-checkbox-secondary ml-2">
                 <input
                   id="swUserPreference"
                   className="custom-control-input"
                   type="checkbox"
-                  checked={isDarkMode()}
-                  disabled={!isUserPreferenceExists}
+                  checked={isDarkMode}
+                  disabled={useOsSettings}
                   onChange={e => userPreferenceSwitchModifiedHandler(e.target.checked)}
                 />
                 <label className="custom-control-label" htmlFor="swUserPreference"></label>
               </div>
-              <span className={isUserPreferenceExists ? '' : 'text-muted'}>Dark</span>
+              <span className={useOsSettings ? '' : 'text-muted'}>Dark</span>
             </div>
           </div>
         </form>
@@ -205,12 +218,13 @@ const PersonalDropdown = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const PersonalDropdownWrapper = withUnstatedContainers(PersonalDropdown, [AppContainer]);
+const PersonalDropdownWrapper = withUnstatedContainers(PersonalDropdown, [AppContainer, NavigationContainer]);
 
 
 PersonalDropdown.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 
 export default withTranslation()(PersonalDropdownWrapper);

+ 4 - 2
src/client/js/components/Navbar/SearchTop.jsx

@@ -4,6 +4,7 @@ import { withTranslation } from 'react-i18next';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
+import NavigationContainer from '../../services/NavigationContainer';
 
 import SearchForm from '../SearchForm';
 
@@ -51,7 +52,7 @@ class SearchTop extends React.Component {
   }
 
   Root = ({ children }) => {
-    const { isDeviceSmallerThanMd: isCollapsed } = this.props.appContainer.state;
+    const { isDeviceSmallerThanMd: isCollapsed } = this.props.navigationContainer.state;
 
     return isCollapsed
       ? (
@@ -116,11 +117,12 @@ class SearchTop extends React.Component {
 SearchTop.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 
 /**
  * Wrapper component for using unstated
  */
-const SearchTopWrapper = withUnstatedContainers(SearchTop, [AppContainer]);
+const SearchTopWrapper = withUnstatedContainers(SearchTop, [AppContainer, NavigationContainer]);
 
 export default withTranslation()(SearchTopWrapper);

+ 30 - 0
src/client/js/components/NotAvailableForGuest.jsx

@@ -0,0 +1,30 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { UncontrolledTooltip } from 'reactstrap';
+
+const NotAvailableForGuest = ({ children }) => {
+
+  const id = children.props.id || `grw-not-available-for-guest-${Math.random().toString(32).substring(2)}`;
+
+  // clone and add className
+  const clonedChild = React.cloneElement(children, {
+    id,
+    className: `${children.props.className} grw-not-available-for-guest`,
+    onClick: () => { /* do nothing */ },
+  });
+
+  return (
+    <>
+      { clonedChild }
+      <UncontrolledTooltip placement="top" target={id}>Not available for guest</UncontrolledTooltip>
+    </>
+  );
+
+};
+
+NotAvailableForGuest.propTypes = {
+  children: PropTypes.node.isRequired,
+};
+
+export default NotAvailableForGuest;

+ 12 - 5
src/client/js/components/Page.jsx

@@ -128,15 +128,22 @@ class Page extends React.Component {
   }
 
   render() {
-    const isMobile = this.props.appContainer.isMobile;
-    const { markdown } = this.props.pageContainer.state;
+    const { appContainer, pageContainer } = this.props;
+    const isMobile = appContainer.isMobile;
+    const isLoggedIn = appContainer.currentUser != null;
+    const { markdown } = pageContainer.state;
 
     return (
       <div className={isMobile ? 'page-mobile' : ''}>
         <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} />
-        <LinkEditModal ref={this.LinkEditModal} />
-        <HandsontableModal ref={this.handsontableModal} onSave={this.saveHandlerForHandsontableModal} />
-        <DrawioModal ref={this.drawioModal} onSave={this.saveHandlerForDrawioModal} />
+
+        { isLoggedIn && (
+          <>
+            <LinkEditModal ref={this.LinkEditModal} />
+            <HandsontableModal ref={this.handsontableModal} onSave={this.saveHandlerForHandsontableModal} />
+            <DrawioModal ref={this.drawioModal} onSave={this.saveHandlerForDrawioModal} />
+          </>
+        )}
       </div>
     );
   }

+ 6 - 4
src/client/js/components/Page/TagLabels.jsx

@@ -6,6 +6,7 @@ import * as toastr from 'toastr';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
+import NavigationContainer from '../../services/NavigationContainer';
 import PageContainer from '../../services/PageContainer';
 import EditorContainer from '../../services/EditorContainer';
 
@@ -30,7 +31,7 @@ class TagLabels extends React.Component {
    *   2. editorContainer.state.tags if editorMode is not null
    */
   getEditTargetData() {
-    const { editorMode } = this.props.appContainer.state;
+    const { editorMode } = this.props.navigationContainer.state;
     return (editorMode == null)
       ? this.props.pageContainer.state.tags
       : this.props.editorContainer.state.tags;
@@ -41,8 +42,8 @@ class TagLabels extends React.Component {
   }
 
   async tagsUpdatedHandler(tags) {
-    const { appContainer, editorContainer } = this.props;
-    const { editorMode } = appContainer.state;
+    const { appContainer, navigationContainer, editorContainer } = this.props;
+    const { editorMode } = navigationContainer.state;
 
     // post api request and update tags
     if (editorMode == null) {
@@ -137,12 +138,13 @@ class TagLabels extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const TagLabelsWrapper = withUnstatedContainers(TagLabels, [AppContainer, PageContainer, EditorContainer]);
+const TagLabelsWrapper = withUnstatedContainers(TagLabels, [AppContainer, NavigationContainer, PageContainer, EditorContainer]);
 
 
 TagLabels.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };

+ 10 - 7
src/client/js/components/PageComment/CommentEditor.jsx

@@ -20,6 +20,7 @@ import Editor from '../PageEditor/Editor';
 import SlackNotification from '../SlackNotification';
 
 import CommentPreview from './CommentPreview';
+import NotAvailableForGuest from '../NotAvailableForGuest';
 
 /**
  *
@@ -242,13 +243,15 @@ class CommentEditor extends React.Component {
   renderBeforeReady() {
     return (
       <div className="text-center">
-        <button
-          type="button"
-          className="btn btn-lg btn-link"
-          onClick={() => this.setState({ isReadyToUse: true })}
-        >
-          <i className="icon-bubble"></i> Add Comment
-        </button>
+        <NotAvailableForGuest>
+          <button
+            type="button"
+            className="btn btn-lg btn-link"
+            onClick={() => this.setState({ isReadyToUse: true })}
+          >
+            <i className="icon-bubble"></i> Add Comment
+          </button>
+        </NotAvailableForGuest>
       </div>
     );
   }

+ 1 - 1
src/client/js/components/PageComments.jsx

@@ -174,7 +174,7 @@ class PageComments extends React.Component {
             </Button>
           </div>
         )}
-        { showEditor && isLoggedIn && (
+        { showEditor && (
           <div className="page-comment-reply-form ml-4 ml-sm-5 mr-3">
             <CommentEditor
               growiRenderer={this.growiRenderer}

+ 9 - 6
src/client/js/components/PageCreateModal.jsx

@@ -10,13 +10,15 @@ import urljoin from 'url-join';
 
 import { userPageRoot } from '@commons/util/path-utils';
 import { pathUtils } from 'growi-commons';
-import { withUnstatedContainers } from './UnstatedUtils';
 
 import AppContainer from '../services/AppContainer';
+import NavigationContainer from '../services/NavigationContainer';
+import { withUnstatedContainers } from './UnstatedUtils';
+
 import PagePathAutoComplete from './PagePathAutoComplete';
 
 const PageCreateModal = (props) => {
-  const { t, appContainer } = props;
+  const { t, appContainer, navigationContainer } = props;
 
   const config = appContainer.getConfig();
   const isReachable = config.isSearchServiceReachable;
@@ -160,7 +162,6 @@ const PageCreateModal = (props) => {
               {isReachable
                 ? (
                   <PagePathAutoComplete
-                    crowi={appContainer}
                     initializedPath={pathname}
                     addTrailingSlash
                     onSubmit={ppacSubmitHandler}
@@ -240,9 +241,10 @@ const PageCreateModal = (props) => {
       </div>
     );
   }
+
   return (
-    <Modal size="lg" isOpen={appContainer.state.isPageCreateModalShown} toggle={appContainer.closePageCreateModal} className="grw-create-page">
-      <ModalHeader tag="h4" toggle={appContainer.closePageCreateModal} className="bg-primary text-light">
+    <Modal size="lg" isOpen={navigationContainer.state.isPageCreateModalShown} toggle={navigationContainer.closePageCreateModal} className="grw-create-page">
+      <ModalHeader tag="h4" toggle={navigationContainer.closePageCreateModal} className="bg-primary text-light">
         { t('New Page') }
       </ModalHeader>
       <ModalBody>
@@ -259,12 +261,13 @@ const PageCreateModal = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const ModalControlWrapper = withUnstatedContainers(PageCreateModal, [AppContainer]);
+const ModalControlWrapper = withUnstatedContainers(PageCreateModal, [AppContainer, NavigationContainer]);
 
 
 PageCreateModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 
 export default withTranslation()(ModalControlWrapper);

+ 0 - 1
src/client/js/components/PageDuplicateModal.jsx

@@ -80,7 +80,6 @@ const PageDuplicateModal = (props) => {
               {isReachable
               ? (
                 <PagePathAutoComplete
-                  crowi={appContainer}
                   initializedPath={path}
                   onSubmit={ppacSubmitHandler}
                   onInputChange={ppacInputChangeHandler}

+ 3 - 1
src/client/js/components/PageEditor/CodeMirrorEditor.jsx

@@ -16,6 +16,7 @@ import pasteHelper from './PasteHelper';
 import EmojiAutoCompleteHelper from './EmojiAutoCompleteHelper';
 import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
 import MarkdownTableInterceptor from './MarkdownTableInterceptor';
+import mlu from './MarkdownLinkUtil';
 import mtu from './MarkdownTableUtil';
 import mdu from './MarkdownDrawioUtil';
 import LinkEditModal from './LinkEditModal';
@@ -653,7 +654,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
   }
 
   showLinkEditHandler() {
-    this.linkEditModal.current.show();
+    this.linkEditModal.current.show(mlu.getSelectedTextInEditor(this.getCodeMirror()));
   }
 
   showHandsonTableHandler() {
@@ -858,6 +859,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
         <LinkEditModal
           ref={this.linkEditModal}
+          onSave={(link) => { return mlu.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), link) }}
         />
         <HandsontableModal
           ref={this.handsontableModal}

+ 181 - 125
src/client/js/components/PageEditor/LinkEditModal.jsx

@@ -1,11 +1,19 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import { Modal, ModalHeader, ModalBody } from 'reactstrap';
-
+import path from 'path';
+import {
+  Modal,
+  ModalHeader,
+  ModalBody,
+  ModalFooter,
+} from 'reactstrap';
 import Preview from './Preview';
+import PagePathAutoComplete from '../PagePathAutoComplete';
 
 import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
+
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 class LinkEditModal extends React.PureComponent {
@@ -16,18 +24,31 @@ class LinkEditModal extends React.PureComponent {
     this.state = {
       show: false,
       isUseRelativePath: false,
-      inputValue: '~.cloud.~',
-      labelInputValue: 'ここがリンク',
+      isUsePermanentLink: false,
+      linkInputValue: '',
+      labelInputValue: '',
+      linkerType: 'mdLink',
       markdown: '',
     };
 
+    this.isApplyPukiwikiLikeLinkerPlugin = window.growiRenderer.preProcessors.some(process => process.constructor.name === 'PukiwikiLikeLinker');
+
+    this.show = this.show.bind(this);
+    this.hide = this.hide.bind(this);
     this.cancel = this.cancel.bind(this);
+    this.inputChangeHandler = this.inputChangeHandler.bind(this);
+    this.submitHandler = this.submitHandler.bind(this);
+    this.handleChangeLabelInput = this.handleChangeLabelInput.bind(this);
+    this.handleSelecteLinkerType = this.handleSelecteLinkerType.bind(this);
     this.toggleIsUseRelativePath = this.toggleIsUseRelativePath.bind(this);
     this.setMarkdown = this.setMarkdown.bind(this);
+    this.toggleIsUsePamanentLink = this.toggleIsUsePamanentLink.bind(this);
+    this.save = this.save.bind(this);
+    this.generateLink = this.generateLink.bind(this);
   }
 
-  show() {
-    this.setState({ show: true });
+  show(defaultLabelInputValue = '') {
+    this.setState({ show: true, labelInputValue: defaultLabelInputValue });
   }
 
   cancel() {
@@ -41,9 +62,16 @@ class LinkEditModal extends React.PureComponent {
   }
 
   toggleIsUseRelativePath() {
+    if (this.state.linkerType === 'growiLink') {
+      return;
+    }
     this.setState({ isUseRelativePath: !this.state.isUseRelativePath });
   }
 
+  toggleIsUsePamanentLink() {
+    this.setState({ isUsePermanentLink: !this.state.isUsePermanentLink });
+  }
+
   renderPreview() {
     return (
       <div className="linkedit-preview">
@@ -62,7 +90,7 @@ class LinkEditModal extends React.PureComponent {
   async setMarkdown() {
     let markdown = '';
     try {
-      await this.props.appContainer.apiGet('/pages.get', { path: this.state.inputValue }).then((res) => {
+      await this.props.appContainer.apiGet('/pages.get', { path: this.state.labelInputValue }).then((res) => {
         markdown = res.page.revision.body;
       });
     }
@@ -72,15 +100,63 @@ class LinkEditModal extends React.PureComponent {
     this.setState({ markdown });
   }
 
-  handleInputChange(linkValue) {
-    this.setState({ inputValue: linkValue });
+  handleChangeLabelInput(label) {
+    this.setState({ labelInputValue: label });
+  }
+
+  handleSelecteLinkerType(linkerType) {
+    if (this.state.isUseRelativePath && linkerType === 'growiLink') {
+      this.toggleIsUseRelativePath();
+    }
+    this.setState({ linkerType });
+  }
+
+  save() {
+    const output = this.generateLink();
+
+    if (this.props.onSave != null) {
+      this.props.onSave(output);
+    }
+
+    this.hide();
+  }
+
+  inputChangeHandler(inputChangeValue) {
+    this.setState({ linkInputValue: inputChangeValue });
+
   }
 
-  labelInputChange(labelValue) {
-    this.setState({ labelInputValue: labelValue });
+  submitHandler(submitValue) {
+    this.setState({ linkInputValue: submitValue });
   }
 
 
+  generateLink() {
+    const { pageContainer } = this.props;
+    const {
+      linkInputValue,
+      labelInputValue,
+      linkerType,
+      isUseRelativePath,
+    } = this.state;
+
+    let reshapedLink = linkInputValue;
+
+    if (isUseRelativePath && linkInputValue.match(/^\//)) {
+      reshapedLink = path.relative(pageContainer.state.path, linkInputValue);
+    }
+
+    if (linkerType === 'pukiwikiLink') {
+      return `[[${labelInputValue}>${reshapedLink}]]`;
+    }
+    if (linkerType === 'growiLink') {
+      return `[${reshapedLink}]`;
+    }
+    if (linkerType === 'mdLink') {
+      return `[${labelInputValue}](${reshapedLink})`;
+    }
+  }
+
   render() {
     return (
       <Modal isOpen={this.state.show} toggle={this.cancel} size="lg">
@@ -89,125 +165,91 @@ class LinkEditModal extends React.PureComponent {
         </ModalHeader>
 
         <ModalBody className="container">
-          <div className="row h-100">
-            <div className="col">
-              <div className="form-gorup my-3">
-                <label htmlFor="linkInput">Link</label>
-                <div className="input-group">
-                  <input
-                    className="form-control"
-                    id="linkInput"
-                    type="text"
-                    placeholder="/foo/bar/31536000"
-                    aria-describedby="button-addon"
-                    value={this.state.inputValue}
-                    onChange={e => this.handleInputChange(e.target.value)}
-                  />
-                  <div className="input-group-append">
-                    <button
-                      type="button"
-                      id="button-addon"
-                      className="btn btn-secondary"
-                      onClick={this.setMarkdown}
-                    >
-                      Preview
-                    </button>
+          <div className="row">
+            <div className="col-12 col-lg-6">
+              <form className="form-group">
+                <div className="form-gorup my-3">
+                  <label htmlFor="linkInput">Link</label>
+                  <div className="input-group">
+                    <PagePathAutoComplete
+                      onInputChange={this.inputChangeHandler}
+                      onSubmit={this.submitHandler}
+                    />
                   </div>
                 </div>
-              </div>
-
-              <div className="d-block d-lg-none mb-3">
-                {this.renderPreview()}
-              </div>
-
-              <div className="linkedit-tabs">
-                <ul className="nav nav-tabs" role="tabist">
-                  <li className="nav-item">
-                    <a className="nav-link active" href="#Pukiwiki" role="tab" data-toggle="tab">
-                      Pukiwiki
-                    </a>
-                  </li>
-                  <li className="nav-item">
-                    <a className="nav-link" href="#Crowi" role="tab" data-toggle="tab">
-                      Crowi
-                    </a>
-                  </li>
-                  <li className="nav-item">
-                    <a className="nav-link" href="#MD" role="tab" data-toggle="tab">
-                      MD
-                    </a>
-                  </li>
-                </ul>
-
-                <div className="tab-content pt-3">
-                  <div id="Pukiwiki" className="tab-pane active" role="tabpanel">
-                    <form className="form-group">
-                      <div className="form-group">
-                        <label htmlFor="pukiwikiLink">Label</label>
-                        <input
-                          type="text"
-                          className="form-control"
-                          id="pukiwikiLink"
-                          value={this.state.labelInputValue}
-                          onChange={e => this.labelInputChange(e.target.value)}
-                        />
-                      </div>
-                      <span className="p-2">[[{this.state.labelInputValue} &gt; {this.state.inputValue}]]</span>
-                      <div>
-                      </div>
-                      <div className="form-inline">
-                        <div className="custom-control custom-checkbox custom-checkbox-info">
-                          <input className="custom-control-input" id="relativePath" type="checkbox" checked={this.state.isUseRelativePath} />
-                          <label className="custom-control-label" htmlFor="relativePath" onClick={this.toggleIsUseRelativePath}>
-                            Use relative path
-                          </label>
-                        </div>
-                        <button type="button" className="btn btn-primary ml-auto">
-                          Done
-                        </button>
-                      </div>
-                    </form>
+                <div className="form-inline">
+                  <div className="custom-control custom-checkbox custom-checkbox-info">
+                    <input
+                      className="custom-control-input"
+                      id="permanentLink"
+                      type="checkbox"
+                      checked={this.state.isUsePamanentLink}
+                    />
+                    <label className="custom-control-label" htmlFor="permanentLink" onClick={this.toggleIsUsePamanentLink}>
+                      Use permanent link
+                    </label>
                   </div>
+                </div>
+              </form>
 
-                  <div id="Crowi" className="tab-pane" role="tabpanel">
-                    <form className="form-group">
-                      <div className="form-group">
-                        <label htmlFor="crowiLink">Label</label>
-                        <input type="text" className="form-control" id="crowiLink"></input>
-                      </div>
-                      <div>
-                        <span>URI</span>
-                      </div>
-                      <div className="d-flex">
-                        <button type="button" className="btn btn-primary ml-auto">
-                          Done
+              <div className="card">
+                <div className="card-body">
+                  <form className="form-group">
+                    <div className="form-group btn-group d-flex" role="group" aria-label="type">
+                      <button
+                        type="button"
+                        name="mdLink"
+                        className={`btn btn-outline-secondary w-100 ${this.state.linkerType === 'mdLink' && 'active'}`}
+                        onClick={e => this.handleSelecteLinkerType(e.target.name)}
+                      >
+                        Markdown
+                      </button>
+                      <button
+                        type="button"
+                        name="growiLink"
+                        className={`btn btn-outline-secondary w-100 ${this.state.linkerType === 'growiLink' && 'active'}`}
+                        onClick={e => this.handleSelecteLinkerType(e.target.name)}
+                      >
+                        Growi Original
+                      </button>
+                      {this.isApplyPukiwikiLikeLinkerPlugin && (
+                        <button
+                          type="button"
+                          name="pukiwikiLink"
+                          className={`btn btn-outline-secondary w-100 ${this.state.linkerType === 'pukiwikiLink' && 'active'}`}
+                          onClick={e => this.handleSelecteLinkerType(e.target.name)}
+                        >
+                          Pukiwiki
                         </button>
-                      </div>
-                    </form>
-                  </div>
+                      )}
+                    </div>
 
-                  <div id="MD" className="tab-pane" role="tabpanel">
-                    <form className="form-group">
-                      <div className="form-group">
-                        <label htmlFor="MDLink">Label</label>
-                        <input type="text" className="form-control" id="MDLink"></input>
-                      </div>
-                      <div>
-                        <span>URI</span>
-                      </div>
-                      <div className="form-inline">
-                        <div className="custom-control custom-checkbox custom-checkbox-info">
-                          <input className="custom-control-input" id="relativePath" type="checkbox" checked={this.state.isUseRelativePath} />
-                          <label className="custom-control-label" htmlFor="relativePath" onClick={this.toggleIsUseRelativePath}>
-                            Use relative path
-                          </label>
-                        </div>
-                        <button type="button" className="btn btn-primary ml-auto">
-                          Done
-                        </button>
+                    <div className="form-group">
+                      <label htmlFor="label">Label</label>
+                      <input
+                        type="text"
+                        className="form-control"
+                        id="label"
+                        value={this.state.labelInputValue}
+                        onChange={e => this.handleChangeLabelInput(e.target.value)}
+                        disabled={this.state.linkerType === 'growiLink'}
+                      />
+                    </div>
+                    <div className="form-inline">
+                      <div className="custom-control custom-checkbox custom-checkbox-info">
+                        <input
+                          className="custom-control-input"
+                          id="relativePath"
+                          type="checkbox"
+                          checked={this.state.isUseRelativePath}
+                          disabled={this.state.linkerType === 'growiLink'}
+                        />
+                        <label className="custom-control-label" htmlFor="relativePath" onClick={this.toggleIsUseRelativePath}>
+                          Use relative path
+                        </label>
                       </div>
-                    </form>
-                  </div>
+                    </div>
+                  </form>
                 </div>
               </div>
             </div>
@@ -217,15 +259,29 @@ class LinkEditModal extends React.PureComponent {
             </div>
           </div>
         </ModalBody>
+        <ModalFooter>
+          <button type="button" className="btn btn-sm btn-outline-secondary" onClick={this.hide}>
+            Cancel
+          </button>
+          <button type="submit" className="btn btn-sm btn-primary" onClick={this.save}>
+            Done
+          </button>
+        </ModalFooter>
       </Modal>
     );
   }
 
 }
 
-const LinkEditModalWrapper = withUnstatedContainers(LinkEditModal, [AppContainer]);
-
 LinkEditModal.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  onSave: PropTypes.func,
 };
+
+/**
+ * Wrapper component for using unstated
+ */
+const LinkEditModalWrapper = withUnstatedContainers(LinkEditModal, [AppContainer, PageContainer]);
+
 export default LinkEditModalWrapper;

+ 19 - 0
src/client/js/components/PageEditor/MarkdownLinkUtil.js

@@ -0,0 +1,19 @@
+/**
+ * Utility for markdown link
+ */
+class MarkdownLinkUtil {
+
+  getSelectedTextInEditor(editor) {
+    return editor.getDoc().getSelection();
+  }
+
+  replaceFocusedMarkdownLinkWithEditor(editor, link) {
+    editor.getDoc().replaceSelection(link);
+  }
+
+}
+
+// singleton pattern
+const instance = new MarkdownLinkUtil();
+Object.freeze(instance);
+export default instance;

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

@@ -286,7 +286,7 @@ class PageEditorByHackmd extends React.Component {
                 disabled={this.state.isInitializing}
                 onClick={() => { return this.resumeToEdit() }}
               >
-                <span className="btn-label"><i className="icon-control-end"></i></span>
+                <span className="btn-label"><i className="icon-fw icon-control-end"></i></span>
                 <span className="btn-text">{t('hackmd.resume_to_edit')}</span>
               </button>
             </div>
@@ -298,7 +298,7 @@ class PageEditorByHackmd extends React.Component {
               type="button"
               onClick={() => { return this.discardChanges() }}
             >
-              <span className="btn-label"><i className="icon-control-start"></i></span>
+              <span className="btn-label"><i className="icon-fw icon-control-start"></i></span>
               <span className="btn-text">{t('hackmd.discard_changes')}</span>
             </button>
           </div>
@@ -322,7 +322,7 @@ class PageEditorByHackmd extends React.Component {
               disabled={isRevisionOutdated || this.state.isInitializing}
               onClick={() => { return this.startToEdit() }}
             >
-              <span className="btn-label"><i className="icon-paper-plane"></i></span>
+              <span className="btn-label"><i className="icon-fw icon-paper-plane"></i></span>
               {t('hackmd.start_to_edit')}
             </button>
           </div>

+ 0 - 2
src/client/js/components/PagePathAutoComplete.jsx

@@ -37,7 +37,6 @@ const PagePathAutoComplete = (props) => {
 
   return (
     <SearchTypeahead
-      crowi={props.crowi}
       onSubmit={submitHandler}
       onChange={inputChangeHandler}
       onInputChange={props.onInputChange}
@@ -51,7 +50,6 @@ const PagePathAutoComplete = (props) => {
 };
 
 PagePathAutoComplete.propTypes = {
-  crowi:            PropTypes.object.isRequired,
   initializedPath:  PropTypes.string,
   addTrailingSlash: PropTypes.bool,
 

+ 9 - 6
src/client/js/components/Sidebar.jsx

@@ -10,6 +10,7 @@ import {
 
 import { withUnstatedContainers } from './UnstatedUtils';
 import AppContainer from '../services/AppContainer';
+import NavigationContainer from '../services/NavigationContainer';
 
 import SidebarNav from './Sidebar/SidebarNav';
 import RecentChanges from './Sidebar/RecentChanges';
@@ -22,6 +23,7 @@ class Sidebar extends React.Component {
 
   static propTypes = {
     appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+    navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
     navigationUIController: PropTypes.any.isRequired,
     isDrawerModeOnInit: PropTypes.bool,
   };
@@ -58,7 +60,7 @@ class Sidebar extends React.Component {
    * return whether drawer mode or not
    */
   get isDrawerMode() {
-    let isDrawerMode = this.props.appContainer.state.isDrawerMode;
+    let isDrawerMode = this.props.navigationContainer.state.isDrawerMode;
     if (isDrawerMode == null) {
       isDrawerMode = this.props.isDrawerModeOnInit;
     }
@@ -120,8 +122,8 @@ class Sidebar extends React.Component {
   }
 
   backdropClickedHandler = () => {
-    const { appContainer } = this.props;
-    appContainer.setState({ isDrawerOpened: false });
+    const { navigationContainer } = this.props;
+    navigationContainer.setState({ isDrawerOpened: false });
   }
 
   itemSelectedHandler = (contentsId) => {
@@ -156,7 +158,7 @@ class Sidebar extends React.Component {
   }
 
   render() {
-    const { isDrawerOpened } = this.props.appContainer.state;
+    const { isDrawerOpened } = this.props.navigationContainer.state;
 
     return (
       <>
@@ -199,7 +201,7 @@ const SidebarWithNavigationUIController = withNavigationUIController(Sidebar);
  */
 
 const SidebarWithNavigation = (props) => {
-  const { preferDrawerModeByUser: isDrawerModeOnInit } = props.appContainer.state;
+  const { preferDrawerModeByUser: isDrawerModeOnInit } = props.navigationContainer.state;
 
   const initUICForDrawerMode = isDrawerModeOnInit
     // generate initialUIController for Drawer mode
@@ -220,6 +222,7 @@ const SidebarWithNavigation = (props) => {
 
 SidebarWithNavigation.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 
-export default withUnstatedContainers(SidebarWithNavigation, [AppContainer]);
+export default withUnstatedContainers(SidebarWithNavigation, [AppContainer, NavigationContainer]);

+ 1 - 1
src/client/js/components/Sidebar/RecentChanges.jsx

@@ -59,7 +59,7 @@ class RecentChanges extends React.Component {
     return (
       <li className="list-group-item p-2">
         <div className="d-flex w-100">
-          <UserPicture user={page.lastUpdatedUser} size="md" />
+          <UserPicture user={page.lastUpdateUser} size="md" />
           <div className="flex-grow-1 ml-2">
             { !dPagePath.isRoot && <FormerLink /> }
             <h5 className="mb-1">

+ 10 - 5
src/client/js/legacy/crowi.js

@@ -240,10 +240,12 @@ $(() => {
 
   // tab changing handling
   $('a[href="#revision-body"]').on('show.bs.tab', () => {
-    appContainer.setEditorMode(null);
+    const navigationContainer = appContainer.getContainer('NavigationContainer');
+    navigationContainer.setEditorMode(null);
   });
   $('a[href="#edit"]').on('show.bs.tab', () => {
-    appContainer.setEditorMode('builtin');
+    const navigationContainer = appContainer.getContainer('NavigationContainer');
+    navigationContainer.setEditorMode('builtin');
     $('body').addClass('on-edit');
     $('body').addClass('builtin-editor');
   });
@@ -252,7 +254,8 @@ $(() => {
     $('body').removeClass('builtin-editor');
   });
   $('a[href="#hackmd"]').on('show.bs.tab', () => {
-    appContainer.setEditorMode('hackmd');
+    const navigationContainer = appContainer.getContainer('NavigationContainer');
+    navigationContainer.setEditorMode('hackmd');
     $('body').addClass('on-edit');
     $('body').addClass('hackmd');
   });
@@ -317,8 +320,10 @@ window.addEventListener('load', (e) => {
 
   // hash on page
   if (window.location.hash) {
+    const navigationContainer = appContainer.getContainer('NavigationContainer');
+
     if ((window.location.hash === '#edit' || window.location.hash === '#edit-form') && $('.tab-pane#edit').length > 0) {
-      appContainer.setState({ editorMode: 'builtin' });
+      navigationContainer.setEditorMode('builtin');
 
       $('a[data-toggle="tab"][href="#edit"]').tab('show');
       $('body').addClass('on-edit');
@@ -328,7 +333,7 @@ window.addEventListener('load', (e) => {
       Crowi.setCaretLineAndFocusToEditor();
     }
     else if (window.location.hash === '#hackmd' && $('.tab-pane#hackmd').length > 0) {
-      appContainer.setState({ editorMode: 'hackmd' });
+      navigationContainer.setEditorMode('hackmd');
 
       $('a[data-toggle="tab"][href="#hackmd"]').tab('show');
       $('body').addClass('on-edit');

+ 2 - 4
src/client/js/nologin.jsx

@@ -5,7 +5,6 @@ import { I18nextProvider } from 'react-i18next';
 
 import i18nFactory from './util/i18n';
 
-import NoLoginContainer from './services/NoLoginContainer';
 import AppContainer from './services/AppContainer';
 
 import InstallerForm from './components/InstallerForm';
@@ -31,9 +30,8 @@ if (installerFormElem) {
 // render loginForm
 const loginFormElem = document.getElementById('login-form');
 if (loginFormElem) {
-  const noLoginContainer = new NoLoginContainer();
   const appContainer = new AppContainer();
-  appContainer.init();
+  appContainer.initApp();
 
   const username = loginFormElem.dataset.username;
   const name = loginFormElem.dataset.name;
@@ -62,7 +60,7 @@ if (loginFormElem) {
 
   ReactDOM.render(
     <I18nextProvider i18n={i18n}>
-      <Provider inject={[noLoginContainer, appContainer]}>
+      <Provider inject={[appContainer]}>
         <LoginForm
           username={username}
           name={name}

+ 42 - 193
src/client/js/services/AppContainer.js

@@ -8,6 +8,10 @@ import InterceptorManager from '@commons/service/interceptor-manager';
 import emojiStrategy from '../util/emojione/emoji_strategy_shrinked.json';
 import GrowiRenderer from '../util/GrowiRenderer';
 
+import {
+  mediaQueryListForDarkMode,
+  applyColorScheme,
+} from '../util/color-scheme';
 import Apiv1ErrorHandler from '../util/apiv1ErrorHandler';
 
 import {
@@ -31,63 +35,29 @@ export default class AppContainer extends Container {
   constructor() {
     super();
 
-    const { localStorage } = window;
-
     this.state = {
-      editorMode: null,
-      isDeviceSmallerThanMd: null,
       preferDarkModeByMediaQuery: false,
-      preferDarkModeByUser: localStorage.preferDarkModeByUser === 'true',
-      preferDrawerModeByUser: localStorage.preferDrawerModeByUser === 'true',
-      preferDrawerModeOnEditByUser: // default: true
-        localStorage.preferDrawerModeOnEditByUser == null || localStorage.preferDrawerModeOnEditByUser === 'true',
-      isDrawerMode: null,
-      isDrawerOpened: false,
-
-      isPageCreateModalShown: false,
 
+      // stetes for contents
       recentlyUpdatedPages: [],
     };
 
     const body = document.querySelector('body');
 
-    this.isAdmin = body.dataset.isAdmin === 'true';
     this.csrfToken = body.dataset.csrftoken;
-    this.isPluginEnabled = body.dataset.pluginEnabled === 'true';
-    this.isLoggedin = document.querySelector('body.nologin') == null;
 
     this.config = JSON.parse(document.getElementById('growi-context-hydrate').textContent || '{}');
 
-    const currentUserElem = document.getElementById('growi-current-user');
-    if (currentUserElem != null) {
-      this.currentUser = JSON.parse(currentUserElem.textContent);
-    }
-
     const userAgent = window.navigator.userAgent.toLowerCase();
     this.isMobile = /iphone|ipad|android/.test(userAgent);
 
-    this.isDocSaved = true;
-
-    this.originRenderer = new GrowiRenderer(this);
-
-    this.interceptorManager = new InterceptorManager();
-    this.interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(this), 10); // process as soon as possible
-    this.interceptorManager.addInterceptor(new DrawioInterceptor(this), 20);
-    this.interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(this), 900); // process as late as possible
-
     const userlang = body.dataset.userlang;
     this.i18n = i18nFactory(userlang);
 
-    if (this.isLoggedin) {
-      // remove old user cache
-      this.removeOldUserCache();
-    }
-
     this.containerInstances = {};
     this.componentInstances = {};
     this.rendererInstances = {};
 
-    this.removeOldUserCache = this.removeOldUserCache.bind(this);
     this.apiGet = this.apiGet.bind(this);
     this.apiPost = this.apiPost.bind(this);
     this.apiDelete = this.apiDelete.bind(this);
@@ -100,23 +70,6 @@ export default class AppContainer extends Container {
       put: this.apiv3Put.bind(this),
       delete: this.apiv3Delete.bind(this),
     };
-
-    this.openPageCreateModal = this.openPageCreateModal.bind(this);
-    this.closePageCreateModal = this.closePageCreateModal.bind(this);
-
-    window.addEventListener('keydown', (event) => {
-      const target = event.target;
-
-      // ignore when target dom is input
-      const inputPattern = /^input|textinput|textarea$/i;
-      if (inputPattern.test(target.tagName) || target.isContentEditable) {
-        return;
-      }
-
-      if (event.key === 'c') {
-        this.setState({ isPageCreateModalShown: true });
-      }
-    });
   }
 
   /**
@@ -126,53 +79,59 @@ export default class AppContainer extends Container {
     return 'AppContainer';
   }
 
-  init() {
-    this.initDeviceSize();
-    this.initColorScheme();
-    this.initPlugins();
+  initApp() {
+    this.initMediaQueryForColorScheme();
+
+    this.injectToWindow();
   }
 
-  initDeviceSize() {
-    const mdOrAvobeHandler = async(mql) => {
-      let isDeviceSmallerThanMd;
+  initContents() {
+    const body = document.querySelector('body');
 
-      // sm -> md
-      if (mql.matches) {
-        isDeviceSmallerThanMd = false;
-      }
-      // md -> sm
-      else {
-        isDeviceSmallerThanMd = true;
-      }
+    const currentUserElem = document.getElementById('growi-current-user');
+    if (currentUserElem != null) {
+      this.currentUser = JSON.parse(currentUserElem.textContent);
+    }
 
-      this.setState({ isDeviceSmallerThanMd });
-      this.updateDrawerMode({ ...this.state, isDeviceSmallerThanMd }); // generate newest state object
-    };
+    this.isAdmin = body.dataset.isAdmin === 'true';
 
-    this.addBreakpointListener('md', mdOrAvobeHandler, true);
+    this.isDocSaved = true;
+
+    this.originRenderer = new GrowiRenderer(this);
+
+    this.interceptorManager = new InterceptorManager();
+    this.interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(this), 10); // process as soon as possible
+    this.interceptorManager.addInterceptor(new DrawioInterceptor(this), 20);
+    this.interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(this), 900); // process as late as possible
+
+    if (this.currentUser != null) {
+      // remove old user cache
+      this.removeOldUserCache();
+    }
+
+    const isPluginEnabled = body.dataset.pluginEnabled === 'true';
+    if (isPluginEnabled) {
+      this.initPlugins();
+    }
+
+    this.injectToWindow();
   }
 
-  async initColorScheme() {
+  async initMediaQueryForColorScheme() {
     const switchStateByMediaQuery = async(mql) => {
       const preferDarkMode = mql.matches;
-      await this.setState({ preferDarkModeByMediaQuery: preferDarkMode });
+      this.setState({ preferDarkModeByMediaQuery: preferDarkMode });
 
-      this.applyColorScheme();
+      applyColorScheme();
     };
 
-    const mqlForDarkMode = window.matchMedia('(prefers-color-scheme: dark)');
     // add event listener
-    mqlForDarkMode.addListener(switchStateByMediaQuery);
-
-    // initialize: check media query
-    switchStateByMediaQuery(mqlForDarkMode);
+    mediaQueryListForDarkMode.addListener(switchStateByMediaQuery);
   }
 
   initPlugins() {
-    if (this.isPluginEnabled) {
-      const growiPlugin = window.growiPlugin;
-      growiPlugin.installAll(this, this.originRenderer);
-    }
+    const growiPlugin = window.growiPlugin;
+    growiPlugin.installAll(this, this.originRenderer);
   }
 
   injectToWindow() {
@@ -331,16 +290,6 @@ export default class AppContainer extends Container {
     this.setState({ recentlyUpdatedPages: data.pages });
   }
 
-  setEditorMode(editorMode) {
-    this.setState({ editorMode });
-    this.updateDrawerMode({ ...this.state, editorMode }); // generate newest state object
-  }
-
-  toggleDrawer() {
-    const { isDrawerOpened } = this.state;
-    this.setState({ isDrawerOpened: !isDrawerOpened });
-  }
-
   launchHandsontableModal(componentKind, beginLineNumber, endLineNumber) {
     let targetComponent;
     switch (componentKind) {
@@ -361,98 +310,6 @@ export default class AppContainer extends Container {
     targetComponent.launchDrawioModal(beginLineNumber, endLineNumber);
   }
 
-  /**
-   * Set Sidebar mode preference by user
-   * @param {boolean} preferDockMode
-   */
-  async setDrawerModePreference(bool) {
-    this.setState({ preferDrawerModeByUser: bool });
-    this.updateDrawerMode({ ...this.state, preferDrawerModeByUser: bool }); // generate newest state object
-
-    // store settings to localStorage
-    const { localStorage } = window;
-    localStorage.preferDrawerModeByUser = bool;
-  }
-
-  /**
-   * Set Sidebar mode preference by user
-   * @param {boolean} preferDockMode
-   */
-  async setDrawerModePreferenceOnEdit(bool) {
-    this.setState({ preferDrawerModeOnEditByUser: bool });
-    this.updateDrawerMode({ ...this.state, preferDrawerModeOnEditByUser: bool }); // generate newest state object
-
-    // store settings to localStorage
-    const { localStorage } = window;
-    localStorage.preferDrawerModeOnEditByUser = bool;
-  }
-
-  /**
-   * Update drawer related state by specified 'newState' object
-   * @param {object} newState A newest state object
-   *
-   * Specify 'newState' like following code:
-   *
-   *   { ...this.state, overwriteParam: overwriteValue }
-   *
-   * because updating state of unstated container will be delayed unless you use await
-   */
-  updateDrawerMode(newState) {
-    const {
-      editorMode, isDeviceSmallerThanMd, preferDrawerModeByUser, preferDrawerModeOnEditByUser,
-    } = newState;
-
-    // get preference on view or edit
-    const preferDrawerMode = editorMode != null ? preferDrawerModeOnEditByUser : preferDrawerModeByUser;
-
-    const isDrawerMode = isDeviceSmallerThanMd || preferDrawerMode;
-    const isDrawerOpened = false; // close Drawer anyway
-
-    this.setState({ isDrawerMode, isDrawerOpened });
-  }
-
-  /**
-   * Set color scheme preference by user
-   * @param {boolean} isDarkMode
-   */
-  async setColorSchemePreference(isDarkMode) {
-    await this.setState({ preferDarkModeByUser: isDarkMode });
-
-    // store settings to localStorage
-    const { localStorage } = window;
-    if (isDarkMode == null) {
-      delete localStorage.removeItem('preferDarkModeByUser');
-    }
-    else {
-      localStorage.preferDarkModeByUser = isDarkMode;
-    }
-
-    this.applyColorScheme();
-  }
-
-  /**
-   * Apply color scheme as 'dark' attribute of <html></html>
-   */
-  applyColorScheme() {
-    const { preferDarkModeByMediaQuery, preferDarkModeByUser } = this.state;
-
-    let isDarkMode = preferDarkModeByMediaQuery;
-    if (preferDarkModeByUser != null) {
-      isDarkMode = preferDarkModeByUser;
-    }
-
-    // switch to dark mode
-    if (isDarkMode) {
-      document.documentElement.removeAttribute('light');
-      document.documentElement.setAttribute('dark', 'true');
-    }
-    // switch to light mode
-    else {
-      document.documentElement.setAttribute('light', 'true');
-      document.documentElement.removeAttribute('dark');
-    }
-  }
-
   async apiGet(path, params) {
     return this.apiRequest('get', path, { params });
   }
@@ -527,12 +384,4 @@ export default class AppContainer extends Container {
     return this.apiv3Request('delete', path, { params });
   }
 
-  openPageCreateModal() {
-    this.setState({ isPageCreateModalShown: true });
-  }
-
-  closePageCreateModal() {
-    this.setState({ isPageCreateModalShown: false });
-  }
-
 }

+ 151 - 0
src/client/js/services/NavigationContainer.js

@@ -0,0 +1,151 @@
+import { Container } from 'unstated';
+
+/**
+ * Service container related to options for Application
+ * @extends {Container} unstated Container
+ */
+export default class NavigationContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+    this.appContainer.registerContainer(this);
+
+    const { localStorage } = window;
+
+    this.state = {
+      editorMode: null,
+
+      isDeviceSmallerThanMd: null,
+      preferDrawerModeByUser: localStorage.preferDrawerModeByUser === 'true',
+      preferDrawerModeOnEditByUser: // default: true
+        localStorage.preferDrawerModeOnEditByUser == null || localStorage.preferDrawerModeOnEditByUser === 'true',
+      isDrawerMode: null,
+      isDrawerOpened: false,
+
+      isPageCreateModalShown: false,
+    };
+
+    this.openPageCreateModal = this.openPageCreateModal.bind(this);
+    this.closePageCreateModal = this.closePageCreateModal.bind(this);
+
+    this.initHotkeys();
+    this.initDeviceSize();
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'NavigationContainer';
+  }
+
+  initHotkeys() {
+    window.addEventListener('keydown', (event) => {
+      const target = event.target;
+
+      // ignore when target dom is input
+      const inputPattern = /^input|textinput|textarea$/i;
+      if (inputPattern.test(target.tagName) || target.isContentEditable) {
+        return;
+      }
+
+      if (event.key === 'c') {
+        // don't fire when not needed
+        if (!event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
+          this.setState({ isPageCreateModalShown: true });
+        }
+      }
+    });
+  }
+
+  initDeviceSize() {
+    const mdOrAvobeHandler = async(mql) => {
+      let isDeviceSmallerThanMd;
+
+      // sm -> md
+      if (mql.matches) {
+        isDeviceSmallerThanMd = false;
+      }
+      // md -> sm
+      else {
+        isDeviceSmallerThanMd = true;
+      }
+
+      this.setState({ isDeviceSmallerThanMd });
+      this.updateDrawerMode({ ...this.state, isDeviceSmallerThanMd }); // generate newest state object
+    };
+
+    this.appContainer.addBreakpointListener('md', mdOrAvobeHandler, true);
+  }
+
+  setEditorMode(editorMode) {
+    this.setState({ editorMode });
+    this.updateDrawerMode({ ...this.state, editorMode }); // generate newest state object
+  }
+
+  toggleDrawer() {
+    const { isDrawerOpened } = this.state;
+    this.setState({ isDrawerOpened: !isDrawerOpened });
+  }
+
+  /**
+   * Set Sidebar mode preference by user
+   * @param {boolean} preferDockMode
+   */
+  async setDrawerModePreference(bool) {
+    this.setState({ preferDrawerModeByUser: bool });
+    this.updateDrawerMode({ ...this.state, preferDrawerModeByUser: bool }); // generate newest state object
+
+    // store settings to localStorage
+    const { localStorage } = window;
+    localStorage.preferDrawerModeByUser = bool;
+  }
+
+  /**
+   * Set Sidebar mode preference by user
+   * @param {boolean} preferDockMode
+   */
+  async setDrawerModePreferenceOnEdit(bool) {
+    this.setState({ preferDrawerModeOnEditByUser: bool });
+    this.updateDrawerMode({ ...this.state, preferDrawerModeOnEditByUser: bool }); // generate newest state object
+
+    // store settings to localStorage
+    const { localStorage } = window;
+    localStorage.preferDrawerModeOnEditByUser = bool;
+  }
+
+  /**
+   * Update drawer related state by specified 'newState' object
+   * @param {object} newState A newest state object
+   *
+   * Specify 'newState' like following code:
+   *
+   *   { ...this.state, overwriteParam: overwriteValue }
+   *
+   * because updating state of unstated container will be delayed unless you use await
+   */
+  updateDrawerMode(newState) {
+    const {
+      editorMode, isDeviceSmallerThanMd, preferDrawerModeByUser, preferDrawerModeOnEditByUser,
+    } = newState;
+
+    // get preference on view or edit
+    const preferDrawerMode = editorMode != null ? preferDrawerModeOnEditByUser : preferDrawerModeByUser;
+
+    const isDrawerMode = isDeviceSmallerThanMd || preferDrawerMode;
+    const isDrawerOpened = false; // close Drawer anyway
+
+    this.setState({ isDrawerMode, isDrawerOpened });
+  }
+
+  openPageCreateModal() {
+    this.setState({ isPageCreateModalShown: true });
+  }
+
+  closePageCreateModal() {
+    this.setState({ isPageCreateModalShown: false });
+  }
+
+}

+ 0 - 23
src/client/js/services/NoLoginContainer.js

@@ -1,23 +0,0 @@
-import { Container } from 'unstated';
-
-/**
- * Service container related to Nologin (installer, login)
- * @extends {Container} unstated Container
- */
-export default class NoLoginContainer extends Container {
-
-  constructor() {
-    super();
-
-    const body = document.querySelector('body');
-    this.csrfToken = body.dataset.csrftoken;
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'NoLoginContainer';
-  }
-
-}

+ 7 - 3
src/client/js/services/PageContainer.js

@@ -179,6 +179,10 @@ export default class PageContainer extends Container {
     }
   }
 
+  get navigationContainer() {
+    return this.appContainer.getContainer('NavigationContainer');
+  }
+
   setLatestRemotePageData(page, user) {
     this.setState({
       remoteRevisionId: page.revision._id,
@@ -199,7 +203,7 @@ export default class PageContainer extends Container {
    * @param {Array[Tag]} tags Array of Tag
    */
   updateStateAfterSave(page, tags) {
-    const { editorMode } = this.appContainer.state;
+    const { editorMode } = this.navigationContainer.state;
 
     // update state of PageContainer
     const newState = {
@@ -243,7 +247,7 @@ export default class PageContainer extends Container {
    * @return {object} { page: Page, tags: Tag[] }
    */
   async save(markdown, optionsToSave = {}) {
-    const { editorMode } = this.appContainer.state;
+    const { editorMode } = this.navigationContainer.state;
 
     const { pageId, path } = this.state;
     let { revisionId } = this.state;
@@ -274,7 +278,7 @@ export default class PageContainer extends Container {
       throw new Error(msg);
     }
 
-    const { editorMode } = this.appContainer.state;
+    const { editorMode } = this.navigationContainer.state;
     if (editorMode == null) {
       logger.warn('\'saveAndReload\' requires the \'errorMode\' param');
       return;

+ 73 - 0
src/client/js/util/color-scheme.js

@@ -0,0 +1,73 @@
+const mediaQueryListForDarkMode = window.matchMedia('(prefers-color-scheme: dark)');
+
+function isUserPreferenceExists() {
+  return localStorage.preferDarkModeByUser != null;
+}
+
+function isPreferedDarkModeByUser() {
+  return localStorage.preferDarkModeByUser === 'true';
+}
+
+function isDarkMode() {
+  if (isUserPreferenceExists()) {
+    return isPreferedDarkModeByUser();
+  }
+  return mediaQueryListForDarkMode.matches;
+}
+
+/**
+ * Apply color scheme as 'dark' attribute of <html></html>
+ */
+function applyColorScheme() {
+  let isDarkMode = mediaQueryListForDarkMode.matches;
+  if (isUserPreferenceExists()) {
+    isDarkMode = isPreferedDarkModeByUser();
+  }
+
+  // switch to dark mode
+  if (isDarkMode) {
+    document.documentElement.removeAttribute('light');
+    document.documentElement.setAttribute('dark', 'true');
+  }
+  // switch to light mode
+  else {
+    document.documentElement.setAttribute('light', 'true');
+    document.documentElement.removeAttribute('dark');
+  }
+}
+
+/**
+ * Remove color scheme preference
+ */
+function removeUserPreference() {
+  if (isUserPreferenceExists()) {
+    delete localStorage.removeItem('preferDarkModeByUser');
+  }
+}
+
+/**
+ * Set color scheme preference
+ * @param {boolean} isDarkMode
+ */
+function updateUserPreference(isDarkMode) {
+  // store settings to localStorage
+  localStorage.preferDarkModeByUser = isDarkMode;
+}
+
+/**
+ * Set color scheme preference with OS settings
+ */
+function updateUserPreferenceWithOsSettings() {
+  localStorage.preferDarkModeByUser = mediaQueryListForDarkMode.matches;
+}
+
+export {
+  mediaQueryListForDarkMode,
+  isUserPreferenceExists,
+  isPreferedDarkModeByUser,
+  isDarkMode,
+  applyColorScheme,
+  removeUserPreference,
+  updateUserPreference,
+  updateUserPreferenceWithOsSettings,
+};

+ 2 - 2
src/client/js/util/reveal/plugins/markdown.js

@@ -83,9 +83,9 @@
 @mixin expand-modal-fullscreen($hasModalHeader: true, $hasModalFooter: true) {
   // full-screen modal
   width: auto;
-  max-width: unset;
+  max-width: unset !important;
   height: calc(100vh - 30px);
-  margin: 15px;
+  margin: 15px !important;
 
   .modal-content {
     height: calc(100vh - 30px);

+ 2 - 0
src/client/styles/scss/_wiki.scss

@@ -8,6 +8,8 @@ div.body {
 }
 
 .wiki {
+  @extend .text-break;
+
   font-size: 15px;
 
   // override line-height except hljs and child of it.

+ 6 - 0
src/client/styles/scss/style-app.scss

@@ -63,14 +63,20 @@
 /*
  * for Guest User Mode
  */
+// TODO: reactify and replace with `grw-not-available-for-guest`
 .dropdown-toggle.dropdown-toggle-disabled {
   cursor: not-allowed;
 }
 
+// TODO: reactify and replace with `grw-not-available-for-guest`
 .edit-button.edit-button-disabled {
   cursor: not-allowed;
 }
 
+.grw-not-available-for-guest {
+  cursor: not-allowed !important;
+}
+
 /*
  * Helper Classes
  */

+ 1 - 1
src/lib/util/mongoose-utils.js

@@ -7,7 +7,7 @@ const getMongoUri = () => {
     || env.MONGODB_URI // MONGOLAB changes their env name
     || env.MONGOHQ_URL
     || env.MONGO_URI
-    || ((env.NODE_ENV === 'test') ? 'mongodb://localhost/growi_test' : 'mongodb://localhost/growi');
+    || ((env.NODE_ENV === 'test') ? 'mongodb://mongo/growi_test' : 'mongodb://mongo/growi');
 };
 
 const getModelSafely = (modelName) => {

+ 2 - 30
src/server/models/user.js

@@ -425,12 +425,8 @@ module.exports = function(crowi) {
       .sort(sort);
   };
 
-  userSchema.statics.findAdmins = function(callback) {
-    this.find({ admin: true })
-      .exec((err, admins) => {
-        debug('Admins: ', admins);
-        callback(err, admins);
-      });
+  userSchema.statics.findAdmins = async function() {
+    return this.find({ admin: true });
   };
 
   userSchema.statics.findUsersByPartOfEmail = function(emailPart, options) {
@@ -556,30 +552,6 @@ module.exports = function(crowi) {
     });
   };
 
-  userSchema.statics.removeCompletelyById = function(id, callback) {
-    const User = this;
-    User.findById(id, (err, userData) => {
-      if (!userData) {
-        return callback(err, null);
-      }
-
-      debug('Removing user:', userData);
-      // 物理削除可能なのは、承認待ちユーザー、招待中ユーザーのみ
-      // 利用を一度開始したユーザーは論理削除のみ可能
-      if (userData.status !== STATUS_REGISTERED && userData.status !== STATUS_INVITED) {
-        return callback(new Error('Cannot remove completely the user whoes status is not INVITED'), null);
-      }
-
-      userData.remove((err) => {
-        if (err) {
-          return callback(err, null);
-        }
-
-        return callback(null, 1);
-      });
-    });
-  };
-
   userSchema.statics.resetPasswordByRandomString = async function(id) {
     const user = await this.findById(id);
 

+ 5 - 61
src/server/routes/admin.js

@@ -4,8 +4,6 @@ module.exports = function(crowi, app) {
   const logger = require('@alias/logger')('growi:routes:admin');
 
   const models = crowi.models;
-  const User = models.User;
-  const ExternalAccount = models.ExternalAccount;
   const UserGroup = models.UserGroup;
   const UserGroupRelation = models.UserGroupRelation;
   const GlobalNotificationSetting = models.GlobalNotificationSetting;
@@ -170,6 +168,7 @@ module.exports = function(crowi, app) {
   // app.get('/admin/notification/slackAuth'     , admin.notification.slackauth);
   actions.notification.slackAuth = function(req, res) {
     const code = req.query.code;
+    const { t } = req;
 
     if (!code || !slackNotificationService.hasSlackConfig()) {
       return res.redirect('/admin/notification');
@@ -182,17 +181,17 @@ module.exports = function(crowi, app) {
 
         try {
           await configManager.updateConfigsInTheSameNamespace('notification', { 'slack:token': data.access_token });
-          req.flash('successMessage', ['Successfully Connected!']);
+          req.flash('successMessage', [t('message.successfully_connected')]);
         }
         catch (err) {
-          req.flash('errorMessage', ['Failed to save access_token. Please try again.']);
+          req.flash('errorMessage', [t('message.fail_to_save_access_token')]);
         }
 
         return res.redirect('/admin/notification');
       })
       .catch((err) => {
         debug('oauth response ERROR', err);
-        req.flash('errorMessage', ['Failed to fetch access_token. Please do connect again.']);
+        req.flash('errorMessage', [t('message.fail_to_fetch_access_token')]);
         return res.redirect('/admin/notification');
       });
   };
@@ -200,7 +199,7 @@ module.exports = function(crowi, app) {
   // app.post('/admin/notification/slackSetting/disconnect' , admin.notification.disconnectFromSlack);
   actions.notification.disconnectFromSlack = async function(req, res) {
     await configManager.updateConfigsInTheSameNamespace('notification', { 'slack:token': '' });
-    req.flash('successMessage', ['Successfully Disconnected!']);
+    req.flash('successMessage', [req.t('successfully_disconnected')]);
 
     return res.redirect('/admin/notification');
   };
@@ -232,66 +231,11 @@ module.exports = function(crowi, app) {
     return res.render('admin/users');
   };
 
-  // これやったときの relation の挙動未確認
-  actions.user.removeCompletely = function(req, res) {
-    // ユーザーの物理削除
-    const id = req.params.id;
-
-    User.removeCompletelyById(id, (err, removed) => {
-      if (err) {
-        debug('Error while removing user.', err, id);
-        req.flash('errorMessage', '完全な削除に失敗しました。');
-      }
-      else {
-        req.flash('successMessage', '削除しました');
-      }
-      return res.redirect('/admin/users');
-    });
-  };
-
-  // app.post('/_api/admin/users.resetPassword' , admin.api.usersResetPassword);
-  actions.user.resetPassword = async function(req, res) {
-    const id = req.body.user_id;
-    const User = crowi.model('User');
-
-    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 = {};
   actions.externalAccount.index = function(req, res) {
     return res.render('admin/external-accounts');
   };
 
-  actions.externalAccount.remove = async function(req, res) {
-    const id = req.params.id;
-
-    let account = null;
-
-    try {
-      account = await ExternalAccount.findByIdAndRemove(id);
-      if (account == null) {
-        throw new Error('削除に失敗しました。');
-      }
-    }
-    catch (err) {
-      req.flash('errorMessage', err.message);
-      return res.redirect('/admin/users/external-accounts');
-    }
-
-    req.flash('successMessage', `外部アカウント '${account.providerType}/${account.accountId}' を削除しました`);
-    return res.redirect('/admin/users/external-accounts');
-  };
 
   actions.userGroup = {};
   actions.userGroup.index = function(req, res) {

+ 2 - 2
src/server/routes/apiv3/notification-setting.js

@@ -34,8 +34,8 @@ const validator = {
     }),
   ],
   notifyForPageGrant: [
-    body('isNotificationForOwnerPageEnabled').isBoolean(),
-    body('isNotificationForGroupPageEnabled').isBoolean(),
+    body('isNotificationForOwnerPageEnabled').if(value => value != null).isBoolean(),
+    body('isNotificationForGroupPageEnabled').if(value => value != null).isBoolean(),
   ],
 };
 

+ 1 - 4
src/server/routes/apiv3/statistics.js

@@ -8,8 +8,6 @@ const router = express.Router();
 
 const helmet = require('helmet');
 
-const util = require('util');
-
 const USER_STATUS_MASTER = {
   1: 'registered',
   2: 'active',
@@ -54,8 +52,7 @@ module.exports = (crowi) => {
     const inactiveUserTotal = userCountResults.invited + userCountResults.deleted + userCountResults.suspended + userCountResults.registered;
 
     // Get admin users
-    const findAdmins = util.promisify(User.findAdmins).bind(User);
-    const adminUsers = await findAdmins();
+    const adminUsers = await User.findAdmins();
 
     return {
       total: activeUserCount + inactiveUserTotal,

+ 0 - 4
src/server/routes/index.js

@@ -93,12 +93,8 @@ module.exports = function(crowi, app) {
   app.get('/admin/global-notification/:id'   , loginRequiredStrictly , adminRequired , admin.globalNotification.detail);
 
   app.get('/admin/users'                , loginRequiredStrictly , adminRequired , admin.user.index);
-  app.post('/admin/user/:id/removeCompletely' , loginRequiredStrictly , adminRequired , csrf, admin.user.removeCompletely);
-  // new route patterns from here:
-  app.post('/_api/admin/users.resetPassword'  , loginRequiredStrictly , adminRequired , csrf, admin.user.resetPassword);
 
   app.get('/admin/users/external-accounts'               , loginRequiredStrictly , adminRequired , admin.externalAccount.index);
-  app.post('/admin/users/external-accounts/:id/remove'   , loginRequiredStrictly , adminRequired , admin.externalAccount.remove);
 
   // user-groups admin
   app.get('/admin/user-groups'             , loginRequiredStrictly, adminRequired, admin.userGroup.index);

+ 7 - 3
src/server/routes/installer.js

@@ -79,7 +79,7 @@ module.exports = function(crowi, app) {
       await adminUser.asyncMakeAdmin();
     }
     catch (err) {
-      req.form.errors.push(`管理ユーザーの作成に失敗しました。${err.message}`);
+      req.form.errors.push(req.t('message.failed_to_create_admin_user', { errMessage: err.message }));
       return res.render('installer');
     }
     // create initial pages
@@ -91,9 +91,13 @@ module.exports = function(crowi, app) {
 
     // login with passport
     req.logIn(adminUser, (err) => {
-      if (err) { return next() }
+      if (err) {
+        req.flash('successMessage', req.t('message.complete_to_install1'));
+        req.session.redirectTo = '/admin/app';
+        return res.redirect('/login');
+      }
 
-      req.flash('successMessage', 'GROWI のインストールが完了しました!はじめに、このページで各種設定を確認してください。');
+      req.flash('successMessage', req.t('message.complete_to_install2'));
       return res.redirect('/admin/app');
     });
   };

+ 12 - 12
src/server/routes/login-passport.js

@@ -34,7 +34,7 @@ module.exports = function(crowi, app) {
    * @param {*} res
    */
   const loginFailureHandler = (req, res, message) => {
-    req.flash('errorMessage', message || 'Sign in failure.');
+    req.flash('errorMessage', message || req.t('message.sign_in_failure'));
     return res.redirect('/login');
   };
 
@@ -44,7 +44,7 @@ module.exports = function(crowi, app) {
    * @param {*} res
    */
   const loginFailure = (req, res) => {
-    return loginFailureHandler(req, res, 'Sign in failure.');
+    return loginFailureHandler(req, res, req.t('message.sign_in_failure'));
   };
 
   /**
@@ -142,7 +142,7 @@ module.exports = function(crowi, app) {
       debug('LdapStrategy has not been set up');
       return res.json(ApiResponse.success({
         status: 'warning',
-        message: 'LdapStrategy has not been set up',
+        message: req.t('message.strategy_has_not_been_set_up', { strategy: 'LdapStrategy' }),
       }));
     }
 
@@ -196,7 +196,7 @@ module.exports = function(crowi, app) {
   const loginWithLocal = (req, res, next) => {
     if (!passportService.isLocalStrategySetup) {
       debug('LocalStrategy has not been set up');
-      req.flash('warningMessage', 'LocalStrategy has not been set up');
+      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'LocalStrategy' }));
       return next();
     }
 
@@ -212,7 +212,7 @@ module.exports = function(crowi, app) {
 
       if (err) { // DB Error
         logger.error('Database Server Error: ', err);
-        req.flash('warningMessage', 'Database Server Error occured.');
+        req.flash('warningMessage', req.t('message.database_error'));
         return next(); // pass and the flash message is displayed when all of authentications are failed.
       }
       if (!user) { return next() }
@@ -227,7 +227,7 @@ module.exports = function(crowi, app) {
   const loginWithGoogle = function(req, res, next) {
     if (!passportService.isGoogleStrategySetup) {
       debug('GoogleStrategy has not been set up');
-      req.flash('warningMessage', 'GoogleStrategy has not been set up');
+      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'GoogleStrategy' }));
       return next();
     }
 
@@ -296,7 +296,7 @@ module.exports = function(crowi, app) {
   const loginWithGitHub = function(req, res, next) {
     if (!passportService.isGitHubStrategySetup) {
       debug('GitHubStrategy has not been set up');
-      req.flash('warningMessage', 'GitHubStrategy has not been set up');
+      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'GitHubStrategy' }));
       return next();
     }
 
@@ -338,7 +338,7 @@ module.exports = function(crowi, app) {
   const loginWithTwitter = function(req, res, next) {
     if (!passportService.isTwitterStrategySetup) {
       debug('TwitterStrategy has not been set up');
-      req.flash('warningMessage', 'TwitterStrategy has not been set up');
+      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'TwitterStrategy' }));
       return next();
     }
 
@@ -380,7 +380,7 @@ module.exports = function(crowi, app) {
   const loginWithOidc = function(req, res, next) {
     if (!passportService.isOidcStrategySetup) {
       debug('OidcStrategy has not been set up');
-      req.flash('warningMessage', 'OidcStrategy has not been set up');
+      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'OidcStrategy' }));
       return next();
     }
 
@@ -428,7 +428,7 @@ module.exports = function(crowi, app) {
   const loginWithSaml = function(req, res, next) {
     if (!passportService.isSamlStrategySetup) {
       debug('SamlStrategy has not been set up');
-      req.flash('warningMessage', 'SamlStrategy has not been set up');
+      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'SamlStrategy' }));
       return next();
     }
 
@@ -496,7 +496,7 @@ module.exports = function(crowi, app) {
   const loginWithBasic = async(req, res, next) => {
     if (!passportService.isBasicStrategySetup) {
       debug('BasicStrategy has not been set up');
-      req.flash('warningMessage', 'Basic has not been set up');
+      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'Basic' }));
       return next();
     }
 
@@ -587,7 +587,7 @@ module.exports = function(crowi, app) {
         return;
       }
       else if (err.name === 'UserUpperLimitException') {
-        req.flash('warningMessage', 'Can not register more than the maximum number of users.');
+        req.flash('warningMessage', req.t('message.maximum_number_of_users'));
         return;
       }
       /* eslint-enable no-else-return */

+ 39 - 42
src/server/routes/login.js

@@ -7,7 +7,6 @@ module.exports = function(crowi, app) {
   const debug = require('debug')('growi:routes:login');
   const logger = require('@alias/logger')('growi:routes:login');
   const path = require('path');
-  const async = require('async');
   const mailer = crowi.getMailer();
   const User = crowi.model('User');
   const { configManager, appService, aclService } = crowi;
@@ -103,16 +102,16 @@ module.exports = function(crowi, app) {
         let isError = false;
         if (!User.isEmailValid(email)) {
           isError = true;
-          req.flash('registerWarningMessage', 'This email address could not be used. (Make sure the allowed email address)');
+          req.flash('registerWarningMessage', req.t('message.email_address_could_not_be_used'));
         }
         if (!isRegisterable) {
           if (!errOn.username) {
             isError = true;
-            req.flash('registerWarningMessage', 'This User ID is not available.');
+            req.flash('registerWarningMessage', req.t('message.user_id_is_not_available'));
           }
           if (!errOn.email) {
             isError = true;
-            req.flash('registerWarningMessage', 'This email address is already registered.');
+            req.flash('registerWarningMessage', req.t('message.email_address_is_already_registered'));
           }
         }
         if (isError) {
@@ -120,54 +119,26 @@ module.exports = function(crowi, app) {
           return res.redirect('/register');
         }
 
-        User.createUserByEmailAndPassword(name, username, email, password, undefined, (err, userData) => {
+        User.createUserByEmailAndPassword(name, username, email, password, undefined, async(err, userData) => {
           if (err) {
             if (err.name === 'UserUpperLimitException') {
-              req.flash('registerWarningMessage', 'Can not register more than the maximum number of users.');
+              req.flash('registerWarningMessage', req.t('message.can_not_register_maximum_number_of_users'));
             }
             else {
-              req.flash('registerWarningMessage', 'Failed to register.');
+              req.flash('registerWarningMessage', req.t('message.failed_to_register'));
             }
             return res.redirect('/register');
           }
 
-
-          // 作成後、承認が必要なモードなら、管理者に通知する
-          const appTitle = appService.getAppTitle();
-          if (configManager.getConfig('crowi', 'security:registrationMode') === aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
-            // TODO send mail
-            User.findAdmins((err, admins) => {
-              async.each(
-                admins,
-                (adminUser, next) => {
-                  mailer.send({
-                    to: adminUser.email,
-                    subject: `[${appTitle}:admin] A New User Created and Waiting for Activation`,
-                    template: path.join(crowi.localeDir, 'en-US/admin/userWaitingActivation.txt'),
-                    vars: {
-                      createdUser: userData,
-                      adminUser,
-                      url: appService.getSiteUrl(),
-                      appTitle,
-                    },
-                  },
-                  (err, s) => {
-                    debug('completed to send email: ', err, s);
-                    next();
-                  });
-                },
-                (err) => {
-                  debug('Sending invitation email completed.', err);
-                },
-              );
-            });
+          if (configManager.getConfig('crowi', 'security:registrationMode') !== aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
+            // send mail asynchronous
+            sendEmailToAllAdmins(userData);
           }
 
-
           // add a flash message to inform the user that processing was successful -- 2017.09.23 Yuki Takei
           // cz. loginSuccess method doesn't work on it's own when using passport
           //      because `req.login()` prepared by passport is not called.
-          req.flash('successMessage', `The user '${userData.username}' is successfully created.`);
+          req.flash('successMessage', req.t('message.successfully_created',{ username: userData.username }));
 
           return loginSuccess(req, res, userData);
         });
@@ -180,6 +151,32 @@ module.exports = function(crowi, app) {
     }
   };
 
+  async function sendEmailToAllAdmins(userData) {
+    // send mails to all admin users (derived from crowi) -- 2020.06.18 Yuki Takei
+    const admins = await User.findAdmins();
+
+    const appTitle = appService.getAppTitle();
+
+    const promises = admins.map((admin) => {
+      return mailer.send({
+        to: admin.email,
+        subject: `[${appTitle}:admin] A New User Created and Waiting for Activation`,
+        template: path.join(crowi.localeDir, 'en-US/admin/userWaitingActivation.txt'),
+        vars: {
+          createdUser: userData,
+          admin,
+          url: appService.getSiteUrl(),
+          appTitle,
+        },
+      });
+    })
+
+    const results = await Promise.allSettled(promises);
+    results
+      .filter(result => result.status === 'rejected')
+      .forEach(result => logger.error(result.reason));
+  }
+
   actions.invited = async function(req, res) {
     if (!req.user) {
       return res.redirect('/login');
@@ -195,7 +192,7 @@ module.exports = function(crowi, app) {
       // check user upper limit
       const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
       if (isUserCountExceedsUpperLimit) {
-        req.flash('warningMessage', 'ユーザーが上限に達したためアクティベートできません。');
+        req.flash('warningMessage', req.t('message.can_not_activate_maximum_number_of_users'));
         return res.redirect('/invited');
       }
 
@@ -206,12 +203,12 @@ module.exports = function(crowi, app) {
           return res.redirect('/');
         }
         catch (err) {
-          req.flash('warningMessage', 'アクティベートに失敗しました。');
+          req.flash('warningMessage', req.t('message.failed_to_activate'));
           return res.render('invited');
         }
       }
       else {
-        req.flash('warningMessage', '利用できないユーザーIDです。');
+        req.flash('warningMessage', req.t('message.unable_to_use_this_user'));
         debug('username', username);
         return res.render('invited');
       }

+ 6 - 0
src/server/service/config-loader.js

@@ -143,6 +143,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     default: null,
   },
+  ELASTICSEARCH_REQUEST_TIMEOUT: {
+    ns:      'crowi',
+    key:     'app:elasticsearchRequestTimeout',
+    type:    TYPES.NUMBER,
+    default: 8000, // msec
+  },
   SEARCHBOX_SSL_URL: {
     ns:      'crowi',
     key:     'app:searchboxSslUrl',

+ 1 - 1
src/server/service/search-delegator/elasticsearch.js

@@ -59,7 +59,7 @@ class ElasticsearchDelegator {
     this.client = new elasticsearch.Client({
       host,
       httpAuth,
-      requestTimeout: 5000,
+      requestTimeout: this.configManager.getConfig('crowi', 'app:elasticsearchRequestTimeout'),
       // log: 'debug',
     });
     this.indexName = indexName;

+ 11 - 17
src/server/util/mailer.js

@@ -93,25 +93,19 @@ module.exports = function(crowi) {
     return mc;
   }
 
-  function send(config, callback = () => {}) {
-    if (mailer) {
-      const templateVars = config.vars || {};
-      return swig.renderFile(
-        config.template,
-        templateVars,
-        (err, output) => {
-          if (err) {
-            throw err;
-          }
-
-          config.text = output;
-          return mailer.sendMail(setupMailConfig(config), callback);
-        },
-      );
+  async function send(config) {
+    if (mailer == null) {
+      throw new Error('Mailer is not completed to set up. Please set up SMTP or AWS setting.');
     }
 
-    logger.debug('Mailer is not completed to set up. Please set up SMTP or AWS setting.');
-    return callback(new Error('Mailer is not completed to set up. Please set up SMTP or AWS setting.'), null);
+    const templateVars = config.vars || {};
+    const output = await swig.renderFile(
+      config.template,
+      templateVars,
+    );
+
+    config.text = output;
+    return mailer.sendMail(setupMailConfig(config));
   }
 
 

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

@@ -163,7 +163,7 @@ module.exports = (crowi) => {
     const isInstalled = await appService.isDBInitialized();
 
     if (isInstalled) {
-      req.flash('errorMessage', 'Application already installed.');
+      req.flash('errorMessage', req.t('message.application_already_installed'));
       return res.redirect('admin'); // admin以外はadminRequiredで'/'にリダイレクトされる
     }
 
@@ -186,7 +186,7 @@ module.exports = (crowi) => {
           && configManager.getConfig('crowi', 'aws:bucket') !== ''
           && configManager.getConfig('crowi', 'aws:accessKeyId') !== ''
           && configManager.getConfig('crowi', 'aws:secretAccessKey') !== '') {
-        req.flash('globalError', 'AWS settings required to use this function. Please ask the administrator.');
+        req.flash('globalError', req.t('message.aws_sttings_required'));
         return res.redirect('/');
       }
 

+ 4 - 2
src/server/views/admin/app.html

@@ -19,6 +19,8 @@
 
   {% include './widget/headers/scripts-for-dev.html' %}
 
+  <script src="{{ webpack_asset('js/boot.js') }}"></script>
+
   <script src="{{ webpack_asset('js/vendors.js') }}" defer></script>
   <script src="{{ webpack_asset('js/commons.js') }}" defer></script>
 
@@ -54,10 +56,10 @@
             <div class="logo">{% include 'widget/logo.html' %}</div>
             <h1 class="my-3">GROWI</h1>
 
-            <div class="login-form-errors">
+            <div class="login-form-errors px-3">
               {% if req.form.errors.length > 0 %}
               <div class="alert alert-danger">
-                <ul>
+                <ul class="mb-0">
                 {% for error in req.form.errors %}
                   <li>{{ error }}</li>
                 {% endfor %}

+ 2 - 0
src/server/views/layout/layout.html

@@ -25,6 +25,8 @@
 
   {% include '../widget/headers/scripts-for-dev.html' %}
 
+  <script src="{{ webpack_asset('js/boot.js') }}"></script>
+
   <script src="{{ webpack_asset('js/vendors.js') }}" defer></script>
   <script src="{{ webpack_asset('js/commons.js') }}" defer></script>
   {% if getConfig('crowi', 'plugin:isEnabledPlugins') %}

Разница между файлами не показана из-за своего большого размера
+ 205 - 110
yarn.lock


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