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

Merge branch 'master' into deny-null-of-page-grant

itizawa 6 лет назад
Родитель
Сommit
3e5f610feb
64 измененных файлов с 1840 добавлено и 930 удалено
  1. 19 1
      CHANGES.md
  2. 3 0
      config/logger/config.dev.js
  3. 12 9
      package.json
  4. 8 0
      resource/cdn-manifests.js
  5. 36 20
      resource/locales/en-US/translation.json
  6. 16 15
      resource/locales/ja/translation.json
  7. 17 163
      src/client/js/app.js
  8. 15 3
      src/client/js/components/Admin/AdminRebuildSearch.jsx
  9. 31 6
      src/client/js/components/Page.jsx
  10. 1 1
      src/client/js/components/PageAttachment.jsx
  11. 2 8
      src/client/js/components/PageComments.jsx
  12. 43 47
      src/client/js/components/PageEditor.jsx
  13. 51 31
      src/client/js/components/PageEditorByHackmd.jsx
  14. 2 2
      src/client/js/components/PageStatusAlert.jsx
  15. 19 11
      src/client/js/components/SavePageControls.jsx
  16. 1 1
      src/client/js/components/SearchPage.js
  17. 107 0
      src/client/js/components/StaffCredit/Contributor.js
  18. 124 0
      src/client/js/components/StaffCredit/StaffCredit.jsx
  19. 1 1
      src/client/js/components/UnstatedUtils.jsx
  20. 16 47
      src/client/js/services/AppContainer.js
  21. 7 0
      src/client/js/services/CommentContainer.js
  22. 28 0
      src/client/js/services/EditorContainer.js
  23. 189 1
      src/client/js/services/PageContainer.js
  24. 8 1
      src/client/js/services/TagContainer.js
  25. 7 0
      src/client/js/services/WebsocketContainer.js
  26. 0 2
      src/client/js/util/GrowiRenderer.js
  27. 0 0
      src/client/js/util/PostProcessor/.keep
  28. 0 85
      src/client/js/util/PostProcessor/CrowiTemplate.js
  29. 56 46
      src/client/styles/scss/_comment.scss
  30. 93 0
      src/client/styles/scss/_staff_credit.scss
  31. 2 0
      src/client/styles/scss/style-app.scss
  32. 17 3
      src/server/crowi/express-init.js
  33. 1 0
      src/server/crowi/index.js
  34. 17 0
      src/server/form/admin/securityPassportOidc.js
  35. 1 0
      src/server/form/index.js
  36. 8 0
      src/server/models/comment.js
  37. 6 0
      src/server/models/config.js
  38. 0 1
      src/server/models/index.js
  39. 0 262
      src/server/models/page-group-relation.js
  40. 0 2
      src/server/models/page.js
  41. 0 2
      src/server/models/user-group.js
  42. 3 3
      src/server/models/user.js
  43. 30 2
      src/server/routes/admin.js
  44. 29 0
      src/server/routes/attachment.js
  45. 5 0
      src/server/routes/avoid-session-routes.js
  46. 1 1
      src/server/routes/comment.js
  47. 5 1
      src/server/routes/index.js
  48. 50 0
      src/server/routes/login-passport.js
  49. 71 0
      src/server/service/passport.js
  50. 4 0
      src/server/util/middlewares.js
  51. 5 0
      src/server/util/swigFunctions.js
  52. 3 3
      src/server/views/admin/external-accounts.html
  53. 8 1
      src/server/views/admin/security.html
  54. 25 25
      src/server/views/admin/users.html
  55. 1 1
      src/server/views/admin/widget/menu.html
  56. 205 0
      src/server/views/admin/widget/passport/oidc.html
  57. 3 0
      src/server/views/layout/layout.html
  58. 11 1
      src/server/views/login.html
  59. 1 2
      src/server/views/me/index.html
  60. 2 1
      src/server/views/modal/create_page.html
  61. 2 0
      src/server/views/modal/delete.html
  62. 3 1
      src/server/views/widget/not_found_content.html
  63. 2 1
      src/server/views/widget/page_alerts.html
  64. 407 116
      yarn.lock

+ 19 - 1
CHANGES.md

@@ -1,11 +1,29 @@
 # CHANGES
 # CHANGES
 
 
-## 3.4.8-RC
+## 3.5.0-RC
+
+### BREAKING CHANGES
+
+* GROWI no longer supports [Crowi Template syntax](https://medium.com/crowi-book/crowi-v1-5-0-5a62e7c6be90)
+
+### Updates
 
 
 * Feature: Comment Thread
 * Feature: Comment Thread
+* Feature: OpenID Connect authentication
+* Feature: Staff Credits with [Konami Code](https://en.wikipedia.org/wiki/Konami_Code)
 * Improvement Draft list
 * Improvement Draft list
+* Fix: Deleting page completely
+* Fix: Search with `prefix:` param with CJK pathname
+* I18n: User Management Details
+* I18n: Group Management Details
 * Support: Apply unstated
 * Support: Apply unstated
 * Support: Upgrade libs
 * Support: Upgrade libs
+    * async
+    * axios
+    * file-loader
+    * googleapis
+    * i18next
+    * migrate-mongo
     * mini-css-extract-plugin
     * mini-css-extract-plugin
     * null-loader
     * null-loader
 
 

+ 3 - 0
config/logger/config.dev.js

@@ -1,6 +1,8 @@
 module.exports = {
 module.exports = {
   default: 'info',
   default: 'info',
 
 
+  // 'express-session': 'debug',
+
   /*
   /*
    * configure level for server
    * configure level for server
    */
    */
@@ -28,4 +30,5 @@ module.exports = {
    */
    */
   'growi:app': 'debug',
   'growi:app': 'debug',
   'growi:services:*': 'debug',
   'growi:services:*': 'debug',
+  'growi:StaffCredit': 'debug',
 };
 };

+ 12 - 9
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "3.4.8-RC",
+  "version": "3.5.0-RC",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",
@@ -60,14 +60,15 @@
     "webpack": "webpack"
     "webpack": "webpack"
   },
   },
   "dependencies": {
   "dependencies": {
-    "async": "^2.3.0",
+    "async": "^3.0.1",
     "aws-sdk": "^2.88.0",
     "aws-sdk": "^2.88.0",
-    "axios": "^0.18.0",
+    "axios": "^0.19.0",
     "basic-auth-connect": "~1.0.0",
     "basic-auth-connect": "~1.0.0",
     "body-parser": "^1.18.2",
     "body-parser": "^1.18.2",
     "bunyan": "^1.8.12",
     "bunyan": "^1.8.12",
     "bunyan-format": "^0.2.1",
     "bunyan-format": "^0.2.1",
-    "check-node-version": "^3.1.1",
+    "//": "see https://github.com/parshap/check-node-version/issues/35",
+    "check-node-version": "=3.3.0",
     "connect-flash": "~0.1.1",
     "connect-flash": "~0.1.1",
     "connect-mongo": "^2.0.1",
     "connect-mongo": "^2.0.1",
     "connect-redis": "^3.3.0",
     "connect-redis": "^3.3.0",
@@ -87,17 +88,17 @@
     "express-session": "^1.16.1",
     "express-session": "^1.16.1",
     "express-validator": "^5.3.1",
     "express-validator": "^5.3.1",
     "express-webpack-assets": "^0.1.0",
     "express-webpack-assets": "^0.1.0",
-    "googleapis": "^39.1.0",
+    "googleapis": "^40.0.0",
     "graceful-fs": "^4.1.11",
     "graceful-fs": "^4.1.11",
     "growi-commons": "^4.0.1",
     "growi-commons": "^4.0.1",
     "helmet": "^3.13.0",
     "helmet": "^3.13.0",
-    "i18next": "^15.0.9",
+    "i18next": "^17.0.3",
     "i18next-express-middleware": "^1.4.1",
     "i18next-express-middleware": "^1.4.1",
     "i18next-node-fs-backend": "^2.1.0",
     "i18next-node-fs-backend": "^2.1.0",
     "i18next-sprintf-postprocessor": "^0.2.2",
     "i18next-sprintf-postprocessor": "^0.2.2",
     "md5": "^2.2.1",
     "md5": "^2.2.1",
     "method-override": "^3.0.0",
     "method-override": "^3.0.0",
-    "migrate-mongo": "^5.0.1",
+    "migrate-mongo": "^6.0.0",
     "mkdirp": "~0.5.1",
     "mkdirp": "~0.5.1",
     "module-alias": "^2.0.6",
     "module-alias": "^2.0.6",
     "mongoose": "^5.4.4",
     "mongoose": "^5.4.4",
@@ -109,6 +110,7 @@
     "nodemailer": "^6.0.0",
     "nodemailer": "^6.0.0",
     "nodemailer-ses-transport": "~1.5.0",
     "nodemailer-ses-transport": "~1.5.0",
     "npm-run-all": "^4.1.2",
     "npm-run-all": "^4.1.2",
+    "openid-client": "^2.5.0",
     "passport": "^0.4.0",
     "passport": "^0.4.0",
     "passport-github": "^1.1.0",
     "passport-github": "^1.1.0",
     "passport-google-auth": "^1.0.2",
     "passport-google-auth": "^1.0.2",
@@ -117,6 +119,7 @@
     "passport-saml": "^1.0.0",
     "passport-saml": "^1.0.0",
     "passport-twitter": "^1.0.4",
     "passport-twitter": "^1.0.4",
     "react-dropzone": "^10.1.3",
     "react-dropzone": "^10.1.3",
+    "react-hotkeys": "^1.1.4",
     "rimraf": "^2.6.1",
     "rimraf": "^2.6.1",
     "slack-node": "^0.1.8",
     "slack-node": "^0.1.8",
     "socket.io": "^2.0.3",
     "socket.io": "^2.0.3",
@@ -160,7 +163,7 @@
     "eslint-plugin-chai-friendly": "^0.4.1",
     "eslint-plugin-chai-friendly": "^0.4.1",
     "eslint-plugin-import": "^2.16.0",
     "eslint-plugin-import": "^2.16.0",
     "eslint-plugin-react": "^7.12.4",
     "eslint-plugin-react": "^7.12.4",
-    "file-loader": "^3.0.1",
+    "file-loader": "^4.0.0",
     "handsontable": "^6.0.1",
     "handsontable": "^6.0.1",
     "i18next-browser-languagedetector": "^3.0.1",
     "i18next-browser-languagedetector": "^3.0.1",
     "imports-loader": "^0.8.0",
     "imports-loader": "^0.8.0",
@@ -187,7 +190,7 @@
     "node-sass": "^4.11.0",
     "node-sass": "^4.11.0",
     "nodelist-foreach-polyfill": "^1.2.0",
     "nodelist-foreach-polyfill": "^1.2.0",
     "normalize-path": "^3.0.0",
     "normalize-path": "^3.0.0",
-    "null-loader": "^2.0.0",
+    "null-loader": "^3.0.0",
     "on-headers": "^1.0.1",
     "on-headers": "^1.0.1",
     "optimize-css-assets-webpack-plugin": "^5.0.0",
     "optimize-css-assets-webpack-plugin": "^5.0.0",
     "penpal": "^4.0.0",
     "penpal": "^4.0.0",

+ 8 - 0
resource/cdn-manifests.js

@@ -87,6 +87,14 @@ module.exports = {
         integrity: '',
         integrity: '',
       },
       },
     },
     },
+    {
+      name: 'Press Start 2P',
+      url: 'https://fonts.googleapis.com/css?family=Press+Start+2P',
+      groups: ['basis'],
+      args: {
+        integrity: '',
+      },
+    },
     {
     {
       name: 'font-awesome',
       name: 'font-awesome',
       url: 'https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css',
       url: 'https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css',

+ 36 - 20
resource/locales/en-US/translation.json

@@ -14,6 +14,7 @@
   "Cancel": "Cancel",
   "Cancel": "Cancel",
   "Create": "Create",
   "Create": "Create",
   "Admin": "Admin",
   "Admin": "Admin",
+  "administrator": "Admin",
   "Tag": "Tag",
   "Tag": "Tag",
   "Tags": "Tags",
   "Tags": "Tags",
   "New": "New",
   "New": "New",
@@ -25,10 +26,7 @@
   "Page Path": "Page Path",
   "Page Path": "Page Path",
   "Category": "Category",
   "Category": "Category",
   "User": "User",
   "User": "User",
-  "User Name": "User Name",
-  "User List": "User List",
-  "Add": "Add",
-  "Method": "Method",
+  "status":"Status",
 
 
   "Update": "Update",
   "Update": "Update",
   "Update Page": "Update Page",
   "Update Page": "Update Page",
@@ -51,7 +49,7 @@
 
 
   "Created": "Created",
   "Created": "Created",
   "Last updated": "Updated",
   "Last updated": "Updated",
-  "Last Login": "Last Login",
+  "Last_Login": "Last Login",
 
 
   "Share": "Share",
   "Share": "Share",
   "Share Link": "Share Link",
   "Share Link": "Share Link",
@@ -108,7 +106,7 @@
   "Markdown Settings": "Markdown Settings",
   "Markdown Settings": "Markdown Settings",
   "Customize": "Customize",
   "Customize": "Customize",
   "Notification Settings": "Notification Settings",
   "Notification Settings": "Notification Settings",
-  "User Management": "User Management",
+  "User_Management": "User Management",
   "External Account management": "External Account management",
   "External Account management": "External Account management",
   "UserGroup Management": "UserGroup Management",
   "UserGroup Management": "UserGroup Management",
   "Full Text Search Management": "Full Text Search Management",
   "Full Text Search Management": "Full Text Search Management",
@@ -492,6 +490,9 @@
     "xss_prevent_setting":"Prevent XSS(Cross Site Scripting)",
     "xss_prevent_setting":"Prevent XSS(Cross Site Scripting)",
     "xss_prevent_setting_link":"Go to Markdown settings",
     "xss_prevent_setting_link":"Go to Markdown settings",
     "callback_URL": "Callback URL",
     "callback_URL": "Callback URL",
+    "providerName": "Provider Name",
+    "issuerHost": "Issuer Host",
+    "scope": "Scope",
     "desc_of_callback_URL": "Use it in the setting of the %s provider",
     "desc_of_callback_URL": "Use it in the setting of the %s provider",
     "guest_mode": {
     "guest_mode": {
       "deny": "Deny Unregistered Users",
       "deny": "Deny Unregistered Users",
@@ -578,10 +579,21 @@
         "register_2": "Register your OAuth App with \"Authorization callback URL\" as <code>%s</code> (where <code>%s</code> is your hostname)",
         "register_2": "Register your OAuth App with \"Authorization callback URL\" as <code>%s</code> (where <code>%s</code> is your hostname)",
         "register_3": "Copy and paste your ClientID and Client Secret above"
         "register_3": "Copy and paste your ClientID and Client Secret above"
       },
       },
+      "OIDC": {
+        "name": "OpenID Connect",
+        "id_detail": "Specification of the name of attribute which can identify the user in OIDC claims",
+        "username_detail": "Specification of mappings for <code>username</code> when creating new users",
+        "name_detail": "Specification of mappings for <code>name</code> when creating new users",
+        "mapping_detail": "Specification of mappings for %s when creating new users",
+        "register_1": "Contant to OIDC IdP Administrator",
+        "register_2": "Register your OIDC App with \"Authorization callback URL\" as <code>%s</code> (where <code>%s</code> is your hostname)",
+        "register_3": "Copy and paste your ClientID and Client Secret above"
+      },
       "how_to": {
       "how_to": {
         "google": "How to configure Google OAuth?",
         "google": "How to configure Google OAuth?",
         "github": "How to configure GitHub OAuth?",
         "github": "How to configure GitHub OAuth?",
-        "twitter": "How to configure Twitter OAuth?"
+        "twitter": "How to configure Twitter OAuth?",
+        "oidc": "How to configure OIDC?"
       }
       }
     },
     },
     "form_item_name": {
     "form_item_name": {
@@ -678,33 +690,37 @@
   },
   },
 
 
   "user_management": {
   "user_management": {
-    "User management": "User management",
-    "invite_users": "Invite new users",
+    "target_user": "Target User",
+    "invite_users": "Invite New Users",
     "emails": "Emails",
     "emails": "Emails",
     "invite_thru_email": "Send Invitation Email",
     "invite_thru_email": "Send Invitation Email",
     "invite": "Invite",
     "invite": "Invite",
-    "give_admin_access": "Give admin access",
-    "remove_admin_access": "Remove admin access",
-    "external_account": "External account management",
+    "invited": "User was invited",
+    "give_admin_access": "Give Admin Access",
+    "remove_admin_access": "Remove Admin Access",
+    "external_account": "External Account Management",
     "external_account_list": "External Account List",
     "external_account_list": "External Account List",
     "back_to_user_management": "Back to User Management",
     "back_to_user_management": "Back to User Management",
     "authentication_provider": "Authentication Provider",
     "authentication_provider": "Authentication Provider",
-    "Manage": "Manage",
-    "Edit menu": "Edit menu",
+    "manage": "Manage",
+    "edit_menu": "Edit Menu",
     "password_setting": "Password Setting",
     "password_setting": "Password Setting",
+    "password_setting_help": "Is password set?",
     "set": "Yes",
     "set": "Yes",
     "unset": "No",
     "unset": "No",
-    "password_setting_help": "Show whether the related user has a password set",
-    "Reissue password": "Reissue password",
+    "temporary_password": "The created user has a temporary password",
+    "send_temporary_password": "Be sure to copy the temporary password ON THIS SCREEN and send it to the user.",
+    "send_new_password": "Please send the new password to the user.",
+    "password_never_seen": "The temporary password can never be retrieved after this screen is closed.",
+    "reset_password": "Reset Password",
     "related_username": "Related user's <code>%s</code>",
     "related_username": "Related user's <code>%s</code>",
-    "Status":"Status",
     "accept": "Accept",
     "accept": "Accept",
-    "Deactivate account":"Deactivate account",
+    "deactivate_account":"Deactivate Account",
     "your_own":"You cannot deactivate your own account",
     "your_own":"You cannot deactivate your own account",
-    "Administrator menu":"Administrator menu",
+    "administrator_menu":"Administrator Menu",
     "cannot_remove":"You cannot remove yourself from administrator",
     "cannot_remove":"You cannot remove yourself from administrator",
     "cannot_invite_maximum_users": "Can not invite more than the maximum number of users.",
     "cannot_invite_maximum_users": "Can not invite more than the maximum number of users.",
-    "current users": "Current users:"
+    "current_users": "Current users:"
   },
   },
 
 
   "user_group_management": {
   "user_group_management": {

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

@@ -14,6 +14,7 @@
   "Cancel": "キャンセル",
   "Cancel": "キャンセル",
   "Create": "作成",
   "Create": "作成",
   "Admin": "管理",
   "Admin": "管理",
+  "administrator": "管理者",
   "Tag": "タグ",
   "Tag": "タグ",
   "Tags": "タグ",
   "Tags": "タグ",
   "New": "作成",
   "New": "作成",
@@ -25,10 +26,7 @@
   "Page Path": "ページパス",
   "Page Path": "ページパス",
   "Category": "カテゴリー",
   "Category": "カテゴリー",
   "User": "ユーザー",
   "User": "ユーザー",
-  "User Name": "ユーザーネーム",
-  "User List": "ユーザーリスト",
-  "Add": "追加",
-  "Method": "方法",
+  "status": "ステータス",
 
 
   "Update": "更新",
   "Update": "更新",
   "Update Page": "ページを更新",
   "Update Page": "ページを更新",
@@ -51,7 +49,7 @@
 
 
   "Created": "作成日",
   "Created": "作成日",
   "Last updated": "最終更新",
   "Last updated": "最終更新",
-  "Last Login": "最終ログイン",
+  "Last_Login": "最終ログイン",
 
 
   "Share": "共有",
   "Share": "共有",
   "Share Link": "共有用リンク",
   "Share Link": "共有用リンク",
@@ -108,7 +106,7 @@
   "Markdown Settings": "マークダウン設定",
   "Markdown Settings": "マークダウン設定",
   "Customize": "カスタマイズ",
   "Customize": "カスタマイズ",
   "Notification Settings": "通知設定",
   "Notification Settings": "通知設定",
-  "User Management": "ユーザー管理",
+  "User_Management": "ユーザー管理",
   "External Account management": "外部アカウント管理",
   "External Account management": "外部アカウント管理",
   "UserGroup Management": "グループ管理",
   "UserGroup Management": "グループ管理",
   "Full Text Search Management": "全文検索管理",
   "Full Text Search Management": "全文検索管理",
@@ -678,34 +676,37 @@
   },
   },
 
 
   "user_management": {
   "user_management": {
-    "User Management": "ユーザー管理",
+    "target_user": "対象ユーザー",
     "invite_users": "新規ユーザーの招待",
     "invite_users": "新規ユーザーの招待",
     "emails": "メールアドレス (複数行入力で複数人招待可能)",
     "emails": "メールアドレス (複数行入力で複数人招待可能)",
     "invite_thru_email": "招待をメールで送信",
     "invite_thru_email": "招待をメールで送信",
     "invite": "招待する",
     "invite": "招待する",
+    "invited": "ユーザーを招待しました",
     "give_admin_access": "管理者にする",
     "give_admin_access": "管理者にする",
     "remove_admin_access": "管理者から外す",
     "remove_admin_access": "管理者から外す",
     "external_account": "外部アカウントの管理",
     "external_account": "外部アカウントの管理",
-    "user_list": "ユーザー一覧",
     "external_account_list": "外部アカウント一覧",
     "external_account_list": "外部アカウント一覧",
     "back_to_user_management": "ユーザー管理に戻る",
     "back_to_user_management": "ユーザー管理に戻る",
     "authentication_provider": "認証情報プロバイダ",
     "authentication_provider": "認証情報プロバイダ",
-    "Manage": "操作",
-    "Edit menu": "編集メニュー",
+    "manage": "操作",
+    "edit_menu": "編集メニュー",
     "password_setting": "パスワード設定",
     "password_setting": "パスワード設定",
     "password_setting_help": "関連付けられているユーザーがパスワードを設定しているかどうかを表示します",
     "password_setting_help": "関連付けられているユーザーがパスワードを設定しているかどうかを表示します",
     "set": "設定済み",
     "set": "設定済み",
     "unset": "未設定",
     "unset": "未設定",
-    "Reissue password": "パスワードの再発行",
+    "temporary_password": "作成したユーザーは仮パスワードが設定されています。",
+    "send_temporary_password": "招待メールを送っていない場合、この画面で必ず仮パスワードをコピーし、招待者へ連絡してください。",
+    "send_new_password": "新規発行したパスワードを、対象ユーザーへ連絡してください。",
+    "password_never_seen": "表示されたパスワードはこの画面を閉じると二度と表示できませんのでご注意ください。",
+    "reset_password": "パスワードの再発行",
     "related_username": "関連付けられているユーザーの <code>%s</code>",
     "related_username": "関連付けられているユーザーの <code>%s</code>",
-    "Status": "ステータス",
     "accept": "承認する",
     "accept": "承認する",
-    "Deactivate account": "アカウント停止",
+    "deactivate_account": "アカウント停止",
     "your_own": "自分自身のアカウントを停止することはできません",
     "your_own": "自分自身のアカウントを停止することはできません",
-    "Administrator menu": "管理者メニュー",
+    "administrator_menu": "管理者メニュー",
     "cannot_remove": "自分自身を管理者から外すことはできません",
     "cannot_remove": "自分自身を管理者から外すことはできません",
     "cannot_invite_maximum_users": "ユーザーが上限に達したため招待できません。",
     "cannot_invite_maximum_users": "ユーザーが上限に達したため招待できません。",
-    "current users": "現在のユーザー数:"
+    "current_users": "現在のユーザー数:"
   },
   },
 
 
   "user_group_management": {
   "user_group_management": {

+ 17 - 163
src/client/js/app.js

@@ -4,7 +4,6 @@ import React from 'react';
 import ReactDOM from 'react-dom';
 import ReactDOM from 'react-dom';
 import { Provider } from 'unstated';
 import { Provider } from 'unstated';
 import { I18nextProvider } from 'react-i18next';
 import { I18nextProvider } from 'react-i18next';
-import * as toastr from 'toastr';
 
 
 import loggerFactory from '@alias/logger';
 import loggerFactory from '@alias/logger';
 import Xss from '@commons/service/xss';
 import Xss from '@commons/service/xss';
@@ -31,6 +30,7 @@ import BookmarkButton from './components/BookmarkButton';
 import LikeButton from './components/LikeButton';
 import LikeButton from './components/LikeButton';
 import PagePathAutoComplete from './components/PagePathAutoComplete';
 import PagePathAutoComplete from './components/PagePathAutoComplete';
 import RecentCreated from './components/RecentCreated/RecentCreated';
 import RecentCreated from './components/RecentCreated/RecentCreated';
+import StaffCredit from './components/StaffCredit/StaffCredit';
 import MyDraftList from './components/MyDraftList/MyDraftList';
 import MyDraftList from './components/MyDraftList/MyDraftList';
 import UserPictureList from './components/User/UserPictureList';
 import UserPictureList from './components/User/UserPictureList';
 
 
@@ -75,157 +75,6 @@ appContainer.injectToWindow();
 
 
 const i18n = appContainer.i18n;
 const i18n = appContainer.i18n;
 
 
-/**
- * save success handler when reloading is not needed
- * @param {object} page Page instance
- */
-const saveWithShortcutSuccessHandler = function(result) {
-  const { page, tags } = result;
-  const { editorMode } = appContainer.state;
-
-  // show toastr
-  toastr.success(undefined, 'Saved successfully', {
-    closeButton: true,
-    progressBar: true,
-    newestOnTop: false,
-    showDuration: '100',
-    hideDuration: '100',
-    timeOut: '1200',
-    extendedTimeOut: '150',
-  });
-
-  // update state of PageContainer
-  const newState = {
-    pageId: page._id,
-    revisionId: page.revision._id,
-    revisionCreatedAt: new Date(page.revision.createdAt).getTime() / 1000,
-    remoteRevisionId: page.revision._id,
-    revisionIdHackmdSynced: page.revisionHackmdSynced,
-    hasDraftOnHackmd: page.hasDraftOnHackmd,
-    markdown: page.revision.body,
-    tags,
-  };
-  pageContainer.setState(newState);
-
-  // update state of EditorContainer
-  editorContainer.setState({ tags });
-
-  // PageEditor component
-  const pageEditor = appContainer.getComponentInstance('PageEditor');
-  if (pageEditor != null) {
-    if (editorMode !== 'builtin') {
-      pageEditor.updateEditorValue(newState.markdown);
-    }
-  }
-  // PageEditorByHackmd component
-  const pageEditorByHackmd = appContainer.getComponentInstance('PageEditorByHackmd');
-  if (pageEditorByHackmd != null) {
-    // reset
-    if (editorMode !== 'hackmd') {
-      pageEditorByHackmd.reset();
-    }
-  }
-
-  // hidden input
-  $('input[name="revision_id"]').val(newState.revisionId);
-};
-
-const errorHandler = function(error) {
-  toastr.error(error.message, 'Error occured', {
-    closeButton: true,
-    progressBar: true,
-    newestOnTop: false,
-    showDuration: '100',
-    hideDuration: '100',
-    timeOut: '3000',
-  });
-};
-
-const saveWithShortcut = function(markdown) {
-  const { editorMode } = appContainer.state;
-
-  const { pageId, path } = pageContainer.state;
-  let { revisionId } = pageContainer.state;
-
-  // get options
-  const options = editorContainer.getCurrentOptionsToSave();
-  options.socketClientId = websocketContainer.getCocketClientId();
-  options.pageTags = editorContainer.state.tags;
-
-  if (editorMode === 'hackmd') {
-    // set option to sync
-    options.isSyncRevisionToHackmd = true;
-    revisionId = pageContainer.state.revisionIdHackmdSynced;
-  }
-
-  let promise;
-  if (pageId == null) {
-    promise = appContainer.createPage(path, markdown, options);
-  }
-  else {
-    promise = appContainer.updatePage(pageId, revisionId, markdown, options);
-  }
-
-  promise
-    .then(saveWithShortcutSuccessHandler)
-    .catch(errorHandler);
-};
-
-const saveWithSubmitButtonSuccessHandler = function() {
-  const { path } = pageContainer.state;
-  editorContainer.clearDraft(path);
-  window.location.href = path;
-};
-
-const saveWithSubmitButton = function(submitOpts) {
-  const { editorMode } = appContainer.state;
-  if (editorMode == null) {
-    // do nothing
-    return;
-  }
-
-  const { pageId, path } = pageContainer.state;
-  let { revisionId } = pageContainer.state;
-  // get options
-  const options = editorContainer.getCurrentOptionsToSave();
-  options.socketClientId = websocketContainer.getSocketClientId();
-  options.pageTags = editorContainer.state.tags;
-
-  // set 'submitOpts.overwriteScopesOfDescendants' to options
-  options.overwriteScopesOfDescendants = submitOpts ? !!submitOpts.overwriteScopesOfDescendants : false;
-
-  let promise;
-  if (editorMode === 'hackmd') {
-    const pageEditorByHackmd = appContainer.getComponentInstance('PageEditorByHackmd');
-    // get markdown
-    promise = pageEditorByHackmd.getMarkdown();
-    // use revisionId of PageEditorByHackmd
-    revisionId = pageContainer.state.revisionIdHackmdSynced;
-    // set option to sync
-    options.isSyncRevisionToHackmd = true;
-  }
-  else {
-    const pageEditor = appContainer.getComponentInstance('PageEditor');
-    // get markdown
-    promise = Promise.resolve(pageEditor.getMarkdown());
-  }
-  // create or update
-  if (pageId == null) {
-    promise = promise.then((markdown) => {
-      return appContainer.createPage(path, markdown, options);
-    });
-  }
-  else {
-    promise = promise.then((markdown) => {
-      return appContainer.updatePage(pageId, revisionId, markdown, options);
-    });
-  }
-
-  promise
-    .then(saveWithSubmitButtonSuccessHandler)
-    .catch(errorHandler);
-};
-
 /**
 /**
  * define components
  * define components
  *  key: id of element
  *  key: id of element
@@ -241,10 +90,10 @@ let componentMappings = {
 
 
   'create-page-name-input': <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} addTrailingSlash />,
   'create-page-name-input': <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} addTrailingSlash />,
 
 
-  'page-editor': <PageEditor onSaveWithShortcut={saveWithShortcut} />,
+  'page-editor': <PageEditor />,
   'page-editor-options-selector': <OptionsSelector crowi={appContainer} />,
   'page-editor-options-selector': <OptionsSelector crowi={appContainer} />,
   'page-status-alert': <PageStatusAlert />,
   'page-status-alert': <PageStatusAlert />,
-  'save-page-controls': <SavePageControls onSubmit={saveWithSubmitButton} />,
+  'save-page-controls': <SavePageControls />,
 
 
   'user-created-list': <RecentCreated />,
   'user-created-list': <RecentCreated />,
   'user-draft-list': <MyDraftList />,
   'user-draft-list': <MyDraftList />,
@@ -253,7 +102,7 @@ let componentMappings = {
 // additional definitions if data exists
 // additional definitions if data exists
 if (pageContainer.state.pageId != null) {
 if (pageContainer.state.pageId != null) {
   componentMappings = Object.assign({
   componentMappings = Object.assign({
-    'page-editor-with-hackmd': <PageEditorByHackmd onSaveWithShortcut={saveWithShortcut} />,
+    'page-editor-with-hackmd': <PageEditorByHackmd />,
     'page-comments-list': <PageComments />,
     'page-comments-list': <PageComments />,
     'page-attachment':  <PageAttachment />,
     'page-attachment':  <PageAttachment />,
     'page-comment-write':  <CommentEditorLazyRenderer />,
     'page-comment-write':  <CommentEditorLazyRenderer />,
@@ -264,12 +113,14 @@ if (pageContainer.state.pageId != null) {
     'bookmark-button-lg':  <BookmarkButton pageId={pageContainer.state.pageId} crowi={appContainer} size="lg" />,
     'bookmark-button-lg':  <BookmarkButton pageId={pageContainer.state.pageId} crowi={appContainer} size="lg" />,
     'rename-page-name-input':  <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
     'rename-page-name-input':  <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
     'duplicate-page-name-input':  <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
     'duplicate-page-name-input':  <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
+
+    'admin-rebuild-search': <AdminRebuildSearch crowi={appContainer} />,
   }, componentMappings);
   }, componentMappings);
 }
 }
 if (pageContainer.state.path != null) {
 if (pageContainer.state.path != null) {
   componentMappings = Object.assign({
   componentMappings = Object.assign({
     // eslint-disable-next-line quote-props
     // eslint-disable-next-line quote-props
-    'page': <Page onSaveWithShortcut={saveWithShortcut} />,
+    'page': <Page />,
     'revision-path':  <RevisionPath pageId={pageContainer.state.pageId} pagePath={pageContainer.state.path} crowi={appContainer} />,
     'revision-path':  <RevisionPath pageId={pageContainer.state.pageId} pagePath={pageContainer.state.path} crowi={appContainer} />,
     'tag-label':  <TagLabels />,
     'tag-label':  <TagLabels />,
   }, componentMappings);
   }, componentMappings);
@@ -320,13 +171,6 @@ if (customHeaderEditorElem != null) {
     customHeaderEditorElem,
     customHeaderEditorElem,
   );
   );
 }
 }
-const adminRebuildSearchElem = document.getElementById('admin-rebuild-search');
-if (adminRebuildSearchElem != null) {
-  ReactDOM.render(
-    <AdminRebuildSearch crowi={appContainer} />,
-    adminRebuildSearchElem,
-  );
-}
 const adminGrantSelectorElem = document.getElementById('admin-delete-user-group-modal');
 const adminGrantSelectorElem = document.getElementById('admin-delete-user-group-modal');
 if (adminGrantSelectorElem != null) {
 if (adminGrantSelectorElem != null) {
   ReactDOM.render(
   ReactDOM.render(
@@ -339,6 +183,16 @@ if (adminGrantSelectorElem != null) {
   );
   );
 }
 }
 
 
+// render for stuff credit
+const pageStuffCreditElem = document.getElementById('staff-credit');
+if (pageStuffCreditElem) {
+  ReactDOM.render(
+    <StaffCredit></StaffCredit>,
+    pageStuffCreditElem,
+  );
+
+}
+
 // うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
 // うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
 $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
 $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
   ReactDOM.render(
   ReactDOM.render(

+ 15 - 3
src/client/js/components/Admin/AdminRebuildSearch.jsx

@@ -1,7 +1,10 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-export default class AdminRebuildSearch extends React.Component {
+import { createSubscribedElement } from '../UnstatedUtils';
+import WebsocketContainer from '../../services/AppContainer';
+
+class AdminRebuildSearch extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -15,7 +18,7 @@ export default class AdminRebuildSearch extends React.Component {
   }
   }
 
 
   componentDidMount() {
   componentDidMount() {
-    const socket = this.props.crowi.getWebSocket();
+    const socket = this.props.webspcketContainer.getWebSocket();
 
 
     socket.on('admin:addPageProgress', (data) => {
     socket.on('admin:addPageProgress', (data) => {
       const newStates = Object.assign(data, { isCompleted: false });
       const newStates = Object.assign(data, { isCompleted: false });
@@ -65,6 +68,15 @@ export default class AdminRebuildSearch extends React.Component {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const AdminRebuildSearchWrapper = (props) => {
+  return createSubscribedElement(AdminRebuildSearch, props, [WebsocketContainer]);
+};
+
 AdminRebuildSearch.propTypes = {
 AdminRebuildSearch.propTypes = {
-  crowi: PropTypes.object.isRequired,
+  webspcketContainer: PropTypes.instanceOf(WebsocketContainer).isRequired,
 };
 };
+
+export default AdminRebuildSearchWrapper;

+ 31 - 6
src/client/js/components/Page.jsx

@@ -1,9 +1,11 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
 
 
 import { createSubscribedElement } from './UnstatedUtils';
 import { createSubscribedElement } from './UnstatedUtils';
 import AppContainer from '../services/AppContainer';
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
 import PageContainer from '../services/PageContainer';
+import EditorContainer from '../services/EditorContainer';
 
 
 import MarkdownTable from '../models/MarkdownTable';
 import MarkdownTable from '../models/MarkdownTable';
 
 
@@ -11,6 +13,8 @@ import RevisionRenderer from './Page/RevisionRenderer';
 import HandsontableModal from './PageEditor/HandsontableModal';
 import HandsontableModal from './PageEditor/HandsontableModal';
 import mtu from './PageEditor/MarkdownTableUtil';
 import mtu from './PageEditor/MarkdownTableUtil';
 
 
+const logger = loggerFactory('growi:Page');
+
 class Page extends React.Component {
 class Page extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
@@ -25,6 +29,10 @@ class Page extends React.Component {
     this.saveHandlerForHandsontableModal = this.saveHandlerForHandsontableModal.bind(this);
     this.saveHandlerForHandsontableModal = this.saveHandlerForHandsontableModal.bind(this);
   }
   }
 
 
+  componentWillMount() {
+    this.props.appContainer.registerComponentInstance('Page', this);
+  }
+
   /**
   /**
    * launch HandsontableModal with data specified by arguments
    * launch HandsontableModal with data specified by arguments
    * @param beginLineNumber
    * @param beginLineNumber
@@ -37,15 +45,33 @@ class Page extends React.Component {
     this.handsontableModal.show(MarkdownTable.fromMarkdownString(tableLines));
     this.handsontableModal.show(MarkdownTable.fromMarkdownString(tableLines));
   }
   }
 
 
-  saveHandlerForHandsontableModal(markdownTable) {
+  async saveHandlerForHandsontableModal(markdownTable) {
+    const { pageContainer, editorContainer } = this.props;
+
     const newMarkdown = mtu.replaceMarkdownTableInMarkdown(
     const newMarkdown = mtu.replaceMarkdownTableInMarkdown(
       markdownTable,
       markdownTable,
       this.props.pageContainer.state.markdown,
       this.props.pageContainer.state.markdown,
       this.state.currentTargetTableArea.beginLineNumber,
       this.state.currentTargetTableArea.beginLineNumber,
       this.state.currentTargetTableArea.endLineNumber,
       this.state.currentTargetTableArea.endLineNumber,
     );
     );
-    this.props.onSaveWithShortcut(newMarkdown);
-    this.setState({ currentTargetTableArea: null });
+
+    try {
+      // disable unsaved warning
+      editorContainer.disableUnsavedWarning();
+
+      // eslint-disable-next-line no-unused-vars
+      const { page, tags } = await pageContainer.save(newMarkdown);
+      logger.debug('success to save');
+
+      pageContainer.showSuccessToastr();
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      pageContainer.showErrorToastr(error);
+    }
+    finally {
+      this.setState({ currentTargetTableArea: null });
+    }
   }
   }
 
 
   render() {
   render() {
@@ -66,15 +92,14 @@ class Page extends React.Component {
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
 const PageWrapper = (props) => {
 const PageWrapper = (props) => {
-  return createSubscribedElement(Page, props, [AppContainer, PageContainer]);
+  return createSubscribedElement(Page, props, [AppContainer, PageContainer, EditorContainer]);
 };
 };
 
 
 
 
 Page.propTypes = {
 Page.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
-  onSaveWithShortcut: PropTypes.func.isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 };
 
 
 export default PageWrapper;
 export default PageWrapper;

+ 1 - 1
src/client/js/components/PageAttachment.js → src/client/js/components/PageAttachment.jsx

@@ -49,7 +49,7 @@ class PageAttachment extends React.Component {
   }
   }
 
 
   checkIfFileInUse(attachment) {
   checkIfFileInUse(attachment) {
-    const { markdown } = this.pageContainer.state;
+    const { markdown } = this.props.pageContainer.state;
 
 
     if (markdown.match(attachment.filePathProxied)) {
     if (markdown.match(attachment.filePathProxied)) {
       return true;
       return true;

+ 2 - 8
src/client/js/components/PageComments.jsx

@@ -74,12 +74,6 @@ class PageComments extends React.Component {
 
 
   deleteComment() {
   deleteComment() {
     const comment = this.state.commentToDelete;
     const comment = this.state.commentToDelete;
-    const comments = this.props.commentContainer.state.comments;
-    comments.forEach((reply) => {
-      if (reply.replyTo === comment._id) {
-        this.props.commentContainer.deleteComment(reply);
-      }
-    });
 
 
     this.props.commentContainer.deleteComment(comment)
     this.props.commentContainer.deleteComment(comment)
       .then(() => {
       .then(() => {
@@ -161,10 +155,10 @@ class PageComments extends React.Component {
                       <div className="col-xs-offset-6 col-sm-offset-6 col-md-offset-6 col-lg-offset-6">
                       <div className="col-xs-offset-6 col-sm-offset-6 col-md-offset-6 col-lg-offset-6">
                         <Button
                         <Button
                           bsStyle="primary"
                           bsStyle="primary"
-                          className="fcbtn btn btn-sm btn-primary btn-outline btn-rounded btn-1b"
+                          className="fcbtn btn btn-outline btn-rounded btn-xxs"
                           onClick={() => { return this.replyButtonClickedHandler(commentId) }}
                           onClick={() => { return this.replyButtonClickedHandler(commentId) }}
                         >
                         >
-                          <i className="icon-bubble"></i> Reply
+                          Reply <i className="fa fa-mail-reply"></i>
                         </Button>
                         </Button>
                       </div>
                       </div>
                     )
                     )

+ 43 - 47
src/client/js/components/PageEditor.jsx

@@ -1,10 +1,9 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
 
 
 import { throttle, debounce } from 'throttle-debounce';
 import { throttle, debounce } from 'throttle-debounce';
 
 
-import * as toastr from 'toastr';
-
 import AppContainer from '../services/AppContainer';
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
 import PageContainer from '../services/PageContainer';
 
 
@@ -14,6 +13,7 @@ import Preview from './PageEditor/Preview';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
 import EditorContainer from '../services/EditorContainer';
 import EditorContainer from '../services/EditorContainer';
 
 
+const logger = loggerFactory('growi:PageEditor');
 
 
 class PageEditor extends React.Component {
 class PageEditor extends React.Component {
 
 
@@ -35,15 +35,13 @@ class PageEditor extends React.Component {
     this.setCaretLine = this.setCaretLine.bind(this);
     this.setCaretLine = this.setCaretLine.bind(this);
     this.focusToEditor = this.focusToEditor.bind(this);
     this.focusToEditor = this.focusToEditor.bind(this);
     this.onMarkdownChanged = this.onMarkdownChanged.bind(this);
     this.onMarkdownChanged = this.onMarkdownChanged.bind(this);
-    this.onSave = this.onSave.bind(this);
+    this.onSaveWithShortcut = this.onSaveWithShortcut.bind(this);
     this.onUpload = this.onUpload.bind(this);
     this.onUpload = this.onUpload.bind(this);
     this.onEditorScroll = this.onEditorScroll.bind(this);
     this.onEditorScroll = this.onEditorScroll.bind(this);
     this.onEditorScrollCursorIntoView = this.onEditorScrollCursorIntoView.bind(this);
     this.onEditorScrollCursorIntoView = this.onEditorScrollCursorIntoView.bind(this);
     this.onPreviewScroll = this.onPreviewScroll.bind(this);
     this.onPreviewScroll = this.onPreviewScroll.bind(this);
     this.saveDraft = this.saveDraft.bind(this);
     this.saveDraft = this.saveDraft.bind(this);
     this.clearDraft = this.clearDraft.bind(this);
     this.clearDraft = this.clearDraft.bind(this);
-    this.apiErrorHandler = this.apiErrorHandler.bind(this);
-    this.showUnsavedWarning = this.showUnsavedWarning.bind(this);
 
 
     // get renderer
     // get renderer
     this.growiRenderer = this.props.appContainer.getRenderer('editor');
     this.growiRenderer = this.props.appContainer.getRenderer('editor');
@@ -59,28 +57,13 @@ class PageEditor extends React.Component {
     this.scrollEditorByPreviewScrollWithThrottle = throttle(20, this.scrollEditorByPreviewScroll);
     this.scrollEditorByPreviewScrollWithThrottle = throttle(20, this.scrollEditorByPreviewScroll);
     this.renderPreviewWithDebounce = debounce(50, throttle(100, this.renderPreview));
     this.renderPreviewWithDebounce = debounce(50, throttle(100, this.renderPreview));
     this.saveDraftWithDebounce = debounce(800, this.saveDraft);
     this.saveDraftWithDebounce = debounce(800, this.saveDraft);
-
   }
   }
 
 
   componentWillMount() {
   componentWillMount() {
-    this.props.appContainer.registerComponentInstance(this);
+    this.props.appContainer.registerComponentInstance('PageEditor', this);
 
 
     // initial rendering
     // initial rendering
     this.renderPreview(this.state.markdown);
     this.renderPreview(this.state.markdown);
-
-    window.addEventListener('beforeunload', this.showUnsavedWarning);
-  }
-
-  componentWillUnmount() {
-    window.removeEventListener('beforeunload', this.showUnsavedWarning);
-  }
-
-  showUnsavedWarning(e) {
-    if (!this.props.appContainer.getIsDocSaved()) {
-      // display browser default message
-      e.returnValue = '';
-      return '';
-    }
   }
   }
 
 
   getMarkdown() {
   getMarkdown() {
@@ -111,12 +94,32 @@ class PageEditor extends React.Component {
   onMarkdownChanged(value) {
   onMarkdownChanged(value) {
     this.renderPreviewWithDebounce(value);
     this.renderPreviewWithDebounce(value);
     this.saveDraftWithDebounce();
     this.saveDraftWithDebounce();
-    this.props.appContainer.setIsDocSaved(false);
   }
   }
 
 
-  onSave() {
-    this.props.onSaveWithShortcut(this.state.markdown);
-    this.props.appContainer.setIsDocSaved(true);
+  /**
+   * save and update state of containers
+   */
+  async onSaveWithShortcut() {
+    const { pageContainer, editorContainer } = this.props;
+    const optionsToSave = editorContainer.getCurrentOptionsToSave();
+
+    try {
+      // disable unsaved warning
+      editorContainer.disableUnsavedWarning();
+
+      // eslint-disable-next-line no-unused-vars
+      const { page, tags } = await pageContainer.save(this.state.markdown, optionsToSave);
+      logger.debug('success to save');
+
+      pageContainer.showSuccessToastr();
+
+      // update state of EditorContainer
+      editorContainer.setState({ tags });
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      pageContainer.showErrorToastr(error);
+    }
   }
   }
 
 
   /**
   /**
@@ -124,9 +127,10 @@ class PageEditor extends React.Component {
    * @param {any} file
    * @param {any} file
    */
    */
   async onUpload(file) {
   async onUpload(file) {
+    const { appContainer, pageContainer } = this.props;
+
     try {
     try {
-      let res = await this.props.appContainer.apiGet('/attachments.limit', {
-        _csrf: this.props.appContainer.csrfToken,
+      let res = await appContainer.apiGet('/attachments.limit', {
         fileSize: file.size,
         fileSize: file.size,
       });
       });
 
 
@@ -135,12 +139,15 @@ class PageEditor extends React.Component {
       }
       }
 
 
       const formData = new FormData();
       const formData = new FormData();
-      formData.append('_csrf', this.props.appContainer.csrfToken);
+      const { pageId, path } = pageContainer.state;
+      formData.append('_csrf', appContainer.csrfToken);
       formData.append('file', file);
       formData.append('file', file);
-      formData.append('path', this.props.pageContainer.state.path);
-      formData.append('page_id', this.state.pageId || 0);
+      formData.append('path', path);
+      if (pageId != null) {
+        formData.append('page_id', pageContainer.state.pageId);
+      }
 
 
-      res = await this.props.appContainer.apiPost('/attachments.add', formData);
+      res = await appContainer.apiPost('/attachments.add', formData);
       const attachment = res.attachment;
       const attachment = res.attachment;
       const fileName = attachment.originalName;
       const fileName = attachment.originalName;
 
 
@@ -154,11 +161,12 @@ class PageEditor extends React.Component {
 
 
       // when if created newly
       // when if created newly
       if (res.pageCreated) {
       if (res.pageCreated) {
-        // do nothing
+        logger.info('Page is created', res.pageCreated._id);
       }
       }
     }
     }
     catch (e) {
     catch (e) {
-      this.apiErrorHandler(e);
+      logger.error('failed to upload', e);
+      pageContainer.showErrorToastr(e);
     }
     }
     finally {
     finally {
       this.editor.terminateUploadingState();
       this.editor.terminateUploadingState();
@@ -268,6 +276,7 @@ class PageEditor extends React.Component {
     if (!pageContainer.state.revisionId) {
     if (!pageContainer.state.revisionId) {
       editorContainer.saveDraft(pageContainer.state.path, this.state.markdown);
       editorContainer.saveDraft(pageContainer.state.path, this.state.markdown);
     }
     }
+    editorContainer.enableUnsavedWarning();
   }
   }
 
 
   clearDraft() {
   clearDraft() {
@@ -309,17 +318,6 @@ class PageEditor extends React.Component {
 
 
   }
   }
 
 
-  apiErrorHandler(error) {
-    toastr.error(error.message, 'Error occured', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '3000',
-    });
-  }
-
   render() {
   render() {
     const config = this.props.appContainer.getConfig();
     const config = this.props.appContainer.getConfig();
     const noCdn = !!config.env.NO_CDN;
     const noCdn = !!config.env.NO_CDN;
@@ -340,7 +338,7 @@ class PageEditor extends React.Component {
             onScrollCursorIntoView={this.onEditorScrollCursorIntoView}
             onScrollCursorIntoView={this.onEditorScrollCursorIntoView}
             onChange={this.onMarkdownChanged}
             onChange={this.onMarkdownChanged}
             onUpload={this.onUpload}
             onUpload={this.onUpload}
-            onSave={this.onSave}
+            onSave={this.onSaveWithShortcut}
           />
           />
         </div>
         </div>
         <div className="col-md-6 hidden-sm hidden-xs page-editor-preview-container">
         <div className="col-md-6 hidden-sm hidden-xs page-editor-preview-container">
@@ -370,8 +368,6 @@ PageEditor.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-
-  onSaveWithShortcut: PropTypes.func.isRequired,
 };
 };
 
 
 export default PageEditorWrapper;
 export default PageEditorWrapper;

+ 51 - 31
src/client/js/components/PageEditorByHackmd.jsx

@@ -1,17 +1,19 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
 
 
 import SplitButton from 'react-bootstrap/es/SplitButton';
 import SplitButton from 'react-bootstrap/es/SplitButton';
 import MenuItem from 'react-bootstrap/es/MenuItem';
 import MenuItem from 'react-bootstrap/es/MenuItem';
 
 
-import * as toastr from 'toastr';
-
 import AppContainer from '../services/AppContainer';
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
 import PageContainer from '../services/PageContainer';
+import EditorContainer from '../services/EditorContainer';
 
 
 import { createSubscribedElement } from './UnstatedUtils';
 import { createSubscribedElement } from './UnstatedUtils';
 import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
 import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
 
 
+const logger = loggerFactory('growi:PageEditorByHackmd');
+
 class PageEditorByHackmd extends React.Component {
 class PageEditorByHackmd extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
@@ -26,13 +28,12 @@ class PageEditorByHackmd extends React.Component {
     this.getHackmdUri = this.getHackmdUri.bind(this);
     this.getHackmdUri = this.getHackmdUri.bind(this);
     this.startToEdit = this.startToEdit.bind(this);
     this.startToEdit = this.startToEdit.bind(this);
     this.resumeToEdit = this.resumeToEdit.bind(this);
     this.resumeToEdit = this.resumeToEdit.bind(this);
+    this.onSaveWithShortcut = this.onSaveWithShortcut.bind(this);
     this.hackmdEditorChangeHandler = this.hackmdEditorChangeHandler.bind(this);
     this.hackmdEditorChangeHandler = this.hackmdEditorChangeHandler.bind(this);
-
-    this.apiErrorHandler = this.apiErrorHandler.bind(this);
   }
   }
 
 
   componentWillMount() {
   componentWillMount() {
-    this.props.appContainer.registerComponentInstance(this);
+    this.props.appContainer.registerComponentInstance('PageEditorByHackmd', this);
   }
   }
 
 
   /**
   /**
@@ -97,7 +98,9 @@ class PageEditorByHackmd extends React.Component {
           revisionIdHackmdSynced: res.revisionIdHackmdSynced,
           revisionIdHackmdSynced: res.revisionIdHackmdSynced,
         });
         });
       })
       })
-      .catch(this.apiErrorHandler)
+      .catch((err) => {
+        pageContainer.showErrorToastr(err);
+      })
       .then(() => {
       .then(() => {
         this.setState({ isInitializing: false });
         this.setState({ isInitializing: false });
       });
       });
@@ -117,12 +120,39 @@ class PageEditorByHackmd extends React.Component {
     this.props.pageContainer.setState({ hasDraftOnHackmd: false });
     this.props.pageContainer.setState({ hasDraftOnHackmd: false });
   }
   }
 
 
+  /**
+   * save and update state of containers
+   * @param {string} markdown
+   */
+  async onSaveWithShortcut(markdown) {
+    const { pageContainer, editorContainer } = this.props;
+    const optionsToSave = editorContainer.getCurrentOptionsToSave();
+
+    try {
+      // disable unsaved warning
+      editorContainer.disableUnsavedWarning();
+
+      // eslint-disable-next-line no-unused-vars
+      const { page, tags } = await pageContainer.save(markdown, optionsToSave);
+      logger.debug('success to save');
+
+      pageContainer.showSuccessToastr();
+
+      // update state of EditorContainer
+      editorContainer.setState({ tags });
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      pageContainer.showErrorToastr(error);
+    }
+  }
+
   /**
   /**
    * onChange event of HackmdEditor handler
    * onChange event of HackmdEditor handler
    */
    */
-  hackmdEditorChangeHandler(body) {
+  async hackmdEditorChangeHandler(body) {
     const hackmdUri = this.getHackmdUri();
     const hackmdUri = this.getHackmdUri();
-    const { pageContainer } = this.props;
+    const { pageContainer, editorContainer } = this.props;
 
 
     if (hackmdUri == null) {
     if (hackmdUri == null) {
       // do nothing
       // do nothing
@@ -130,31 +160,22 @@ class PageEditorByHackmd extends React.Component {
     }
     }
 
 
     // do nothing if contents are same
     // do nothing if contents are same
-    if (pageContainer.state.markdown === body) {
+    if (this.state.markdown === body) {
       return;
       return;
     }
     }
 
 
+    // enable unsaved warning
+    editorContainer.enableUnsavedWarning();
+
     const params = {
     const params = {
       pageId: pageContainer.state.pageId,
       pageId: pageContainer.state.pageId,
     };
     };
-    this.props.appContainer.apiPost('/hackmd.saveOnHackmd', params)
-      .then((res) => {
-        // do nothing
-      })
-      .catch((err) => {
-        // do nothing
-      });
-  }
-
-  apiErrorHandler(error) {
-    toastr.error(error.message, 'Error occured', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '3000',
-    });
+    try {
+      await this.props.appContainer.apiPost('/hackmd.saveOnHackmd', params);
+    }
+    catch (err) {
+      logger.error(err);
+    }
   }
   }
 
 
   render() {
   render() {
@@ -176,7 +197,7 @@ class PageEditorByHackmd extends React.Component {
           initializationMarkdown={isResume ? null : this.state.markdown}
           initializationMarkdown={isResume ? null : this.state.markdown}
           onChange={this.hackmdEditorChangeHandler}
           onChange={this.hackmdEditorChangeHandler}
           onSaveWithShortcut={(document) => {
           onSaveWithShortcut={(document) => {
-            this.props.onSaveWithShortcut(document);
+            this.onSaveWithShortcut(document);
           }}
           }}
         >
         >
         </HackmdEditor>
         </HackmdEditor>
@@ -292,14 +313,13 @@ class PageEditorByHackmd extends React.Component {
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
 const PageEditorByHackmdWrapper = (props) => {
 const PageEditorByHackmdWrapper = (props) => {
-  return createSubscribedElement(PageEditorByHackmd, props, [AppContainer, PageContainer]);
+  return createSubscribedElement(PageEditorByHackmd, props, [AppContainer, PageContainer, EditorContainer]);
 };
 };
 
 
 PageEditorByHackmd.propTypes = {
 PageEditorByHackmd.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
-  onSaveWithShortcut: PropTypes.func.isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 };
 
 
 export default PageEditorByHackmdWrapper;
 export default PageEditorByHackmdWrapper;

+ 2 - 2
src/client/js/components/PageStatusAlert.jsx

@@ -31,7 +31,7 @@ class PageStatusAlert extends React.Component {
   }
   }
 
 
   componentWillMount() {
   componentWillMount() {
-    this.props.appContainer.registerComponentInstance(this);
+    this.props.appContainer.registerComponentInstance('PageStatusAlert', this);
   }
   }
 
 
   refreshPage() {
   refreshPage() {
@@ -80,7 +80,7 @@ class PageStatusAlert extends React.Component {
         &nbsp;
         &nbsp;
         <i className="fa fa-angle-double-right"></i>
         <i className="fa fa-angle-double-right"></i>
         &nbsp;
         &nbsp;
-        <a onClick={this.refreshPage}>
+        <a href="#" onClick={this.refreshPage}>
           {label2}
           {label2}
         </a>
         </a>
       </div>
       </div>

+ 19 - 11
src/client/js/components/SavePageControls.jsx

@@ -29,8 +29,8 @@ class SavePageControls extends React.Component {
     this.slackChannelsChangedHandler = this.slackChannelsChangedHandler.bind(this);
     this.slackChannelsChangedHandler = this.slackChannelsChangedHandler.bind(this);
     this.updateGrantHandler = this.updateGrantHandler.bind(this);
     this.updateGrantHandler = this.updateGrantHandler.bind(this);
 
 
-    this.submit = this.submit.bind(this);
-    this.submitAndOverwriteScopesOfDescendants = this.submitAndOverwriteScopesOfDescendants.bind(this);
+    this.save = this.save.bind(this);
+    this.saveAndOverwriteScopesOfDescendants = this.saveAndOverwriteScopesOfDescendants.bind(this);
   }
   }
 
 
   slackEnabledFlagChangedHandler(isSlackEnabled) {
   slackEnabledFlagChangedHandler(isSlackEnabled) {
@@ -45,13 +45,23 @@ class SavePageControls extends React.Component {
     this.props.editorContainer.setState(data);
     this.props.editorContainer.setState(data);
   }
   }
 
 
-  submit() {
-    this.props.appContainer.setIsDocSaved(true);
-    this.props.onSubmit();
+  save() {
+    const { pageContainer, editorContainer } = this.props;
+    // disable unsaved warning
+    editorContainer.disableUnsavedWarning();
+    // save
+    pageContainer.saveAndReload(editorContainer.getCurrentOptionsToSave());
   }
   }
 
 
-  submitAndOverwriteScopesOfDescendants() {
-    this.props.onSubmit({ overwriteScopesOfDescendants: true });
+  saveAndOverwriteScopesOfDescendants() {
+    const { pageContainer, editorContainer } = this.props;
+    // disable unsaved warning
+    editorContainer.disableUnsavedWarning();
+    // save
+    const optionsToSave = Object.assign(editorContainer.getCurrentOptionsToSave(), {
+      overwriteScopesOfDescendants: true,
+    });
+    pageContainer.saveAndReload(optionsToSave);
   }
   }
 
 
   render() {
   render() {
@@ -94,10 +104,10 @@ class SavePageControls extends React.Component {
             className="btn-submit"
             className="btn-submit"
             dropup
             dropup
             pullRight
             pullRight
-            onClick={this.submit}
+            onClick={this.save}
             title={labelSubmitButton}
             title={labelSubmitButton}
           >
           >
-            <MenuItem eventKey="1" onClick={this.submitAndOverwriteScopesOfDescendants}>{labelOverwriteScopes}</MenuItem>
+            <MenuItem eventKey="1" onClick={this.saveAndOverwriteScopesOfDescendants}>{labelOverwriteScopes}</MenuItem>
             {/* <MenuItem divider /> */}
             {/* <MenuItem divider /> */}
           </SplitButton>
           </SplitButton>
         </ButtonToolbar>
         </ButtonToolbar>
@@ -120,8 +130,6 @@ SavePageControls.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-
-  onSubmit: PropTypes.func.isRequired,
 };
 };
 
 
 export default withTranslation()(SavePageControlsWrapper);
 export default withTranslation()(SavePageControlsWrapper);

+ 1 - 1
src/client/js/components/SearchPage.js

@@ -16,7 +16,7 @@ class SearchPage extends React.Component {
     super(props);
     super(props);
 
 
     this.state = {
     this.state = {
-      searchingKeyword: this.props.query.q || '',
+      searchingKeyword: decodeURI(this.props.query.q) || '',
       searchedKeyword: '',
       searchedKeyword: '',
       searchedPages: [],
       searchedPages: [],
       searchResultMeta: {},
       searchResultMeta: {},

+ 107 - 0
src/client/js/components/StaffCredit/Contributor.js

@@ -0,0 +1,107 @@
+const contributors = [
+  {
+    sectionName: 'GROWI VILLAGE',
+    additionalClass: '',
+    memberGroups: [
+      {
+        additionalClass: 'col-md-12 my-4',
+        members: [
+          { position: 'Founder', name: 'yuki-takei' },
+          { position: 'Soncho 1st', name: 'mizozobu' },
+          { position: 'Soncho 2nd', name: 'yusuketk' },
+        ],
+      },
+      {
+        additionalClass: 'col-md-6 my-4',
+        members: [
+          { name: 'utsushiiro' },
+          { name: 'mayumorita' },
+          { name: 'TatsuyaIse' },
+          { name: 'shinoka7' },
+          { name: 'SeiyaTashiro' },
+          { name: 'itizawa' },
+          { name: 'TsuyoshiSuzukief' },
+          { name: 'Yuchan4342' },
+          { name: 'ryu-sato' },
+          { name: 'haruhikonyan' },
+          { name: 'KazuyaNagase' },
+          { name: 'kaishuu0123' },
+          { name: 'kouki-o' },
+          { name: 'Angola' },
+        ],
+      },
+    ],
+  },
+  {
+    sectionName: 'CONTRIBUTER',
+    additionalClass: '',
+    memberGroups: [
+      {
+        additionalClass: 'col-md-4 my-4',
+        members: [
+          { name: 'inductor' },
+          { name: 'shield-9' },
+          { name: 'yaodingyd' },
+          { name: 'hitochan777' },
+          { name: 'ttaka66' },
+          { name: 'watagashi' },
+          { name: 'nt-7' },
+          { name: 'hideo54' },
+          { name: 'wadahiro' },
+        ],
+      },
+      {
+        additionalClass: 'col-md-6 my-4',
+        members: [
+          { name: 'shaminmeerankutty' },
+          { name: 'rabitarochan' },
+        ],
+      },
+      {
+        additionalClass: 'col-md-4 my-4',
+        members: [
+          { name: 'fumitti' },
+          { name: 'fmy' },
+          { name: 'yaamai' },
+          { name: 'ta2yak' },
+          { name: 'ryo33' },
+          { name: 'r-tateshina' },
+          { name: 'nekoruri' },
+          { name: 'kmyk' },
+          { name: 'aximov' },
+        ],
+      },
+    ],
+  },
+  // {
+  //   sectionName: 'VALNERABILITY HUNTER',
+  //   additionalClass: '',
+  //   memberGroups: [
+  //     {
+  //       additionalClass: 'col-md-6 my-4',
+  //       members: [
+  //         { name: 'Yoshinori Hayashi' },
+  //         { name: 'Kanta Nishitani' },
+  //         { name: 'Takashi Yoneuchi' },
+  //         { position: 'DeCurret', name: 'Yusuke Tanomogi' },
+  //       ],
+  //     },
+  //   ],
+  // },
+  {
+    sectionName: 'SPECIAL THANKS',
+    additionalClass: '',
+    memberGroups: [
+      {
+        additionalClass: 'col-md-4 my-4',
+        members: [
+          { name: 'Crowi Team' },
+          { position: 'Ambassador', name: 'Tsuyoshi Suzuki' },
+          { name: 'JPCERT/CC' },
+        ],
+      },
+    ],
+  },
+];
+
+module.exports = contributors;

+ 124 - 0
src/client/js/components/StaffCredit/StaffCredit.jsx

@@ -0,0 +1,124 @@
+import React from 'react';
+import { HotKeys } from 'react-hotkeys';
+
+import loggerFactory from '@alias/logger';
+
+import contributors from './Contributor';
+
+/**
+ * Page staff credit component
+ *
+ * @export
+ * @class StaffCredit
+ * @extends {React.Component}
+ */
+
+export default class StaffCredit extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.logger = loggerFactory('growi:StaffCredit');
+
+    this.state = {
+      isShown: false,
+      userCommand: [],
+    };
+    this.konamiCommand = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a'];
+    this.deleteCredit = this.deleteCredit.bind(this);
+  }
+
+  check(event) {
+    this.logger.debug(`'${event.key}' pressed`);
+
+    // compare keydown and next konamiCommand
+    if (this.konamiCommand[this.state.userCommand.length] === event.key) {
+      const nextValue = this.state.userCommand.concat(event.key);
+      if (nextValue.length === this.konamiCommand.length) {
+        this.setState({
+          isShown: true,
+          userCommand: [],
+        });
+      }
+      else {
+        // add UserCommand
+        this.setState({ userCommand: nextValue });
+
+        this.logger.debug('userCommand', this.state.userCommand);
+      }
+    }
+    else {
+      this.setState({ userCommand: [] });
+    }
+  }
+
+  deleteCredit() {
+    if (this.state.isShown) {
+      this.setState({ isShown: false });
+    }
+  }
+
+  renderMembers(memberGroup, keyPrefix) {
+    // construct members elements
+    const members = memberGroup.members.map((member) => {
+      return (
+        <div className={memberGroup.additionalClass} key={`${keyPrefix}-${member.name}-container`}>
+          <span className="dev-position" key={`${keyPrefix}-${member.name}-position`}>
+            {/* position or '&nbsp;' */}
+            { member.position || '\u00A0' }
+          </span>
+          <p className="dev-name" key={`${keyPrefix}-${member.name}`}>{member.name}</p>
+        </div>
+      );
+    });
+    return (
+      <React.Fragment key={`${keyPrefix}-fragment`}>
+        {members}
+      </React.Fragment>
+    );
+  }
+
+  renderContributors() {
+    if (this.state.isShown) {
+      const credit = contributors.map((contributor) => {
+        // construct members elements
+        const memberGroups = contributor.memberGroups.map((memberGroup, idx) => {
+          return this.renderMembers(memberGroup, `${contributor.sectionName}-group${idx}`);
+        });
+        return (
+          <React.Fragment key={`${contributor.sectionName}-fragment`}>
+            <div className={`row staff-credit-my-10 ${contributor.additionalClass}`} key={`${contributor.sectionName}-row`}>
+              <h2 className="col-md-12 dev-team mt-5 staff-credit-mb-10" key={contributor.sectionName}>{contributor.sectionName}</h2>
+              {memberGroups}
+            </div>
+            <div className="clearfix"></div>
+          </React.Fragment>
+        );
+      });
+      return (
+        <div className="text-center credit-curtain" onClick={this.deleteCredit}>
+          <div className="credit-body">
+            <h1 className="staff-credit-mb-10">GROWI Contributors</h1>
+            <div className="clearfix"></div>
+            {credit}
+          </div>
+        </div>
+      );
+    }
+    return null;
+  }
+
+  render() {
+    const keyMap = { check: ['up', 'down', 'right', 'left', 'b', 'a'] };
+    const handlers = { check: (event) => { return this.check(event) } };
+    return (
+      <HotKeys focused attach={window} keyMap={keyMap} handlers={handlers}>
+        {this.renderContributors()}
+      </HotKeys>
+    );
+  }
+
+}
+
+StaffCredit.propTypes = {
+};

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

@@ -19,7 +19,7 @@ function generateAutoNamedProps(instances) {
 
 
   instances.forEach((instance) => {
   instances.forEach((instance) => {
     // get class name
     // get class name
-    const className = instance.constructor.name;
+    const className = instance.constructor.getClassName();
     // convert initial charactor to lower case
     // convert initial charactor to lower case
     const propName = `${className.charAt(0).toLowerCase()}${className.slice(1)}`;
     const propName = `${className.charAt(0).toLowerCase()}${className.slice(1)}`;
 
 

+ 16 - 47
src/client/js/services/AppContainer.js

@@ -70,6 +70,13 @@ export default class AppContainer extends Container {
     this.apiRequest = this.apiRequest.bind(this);
     this.apiRequest = this.apiRequest.bind(this);
   }
   }
 
 
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AppContainer';
+  }
+
   initPlugins() {
   initPlugins() {
     if (this.isPluginEnabled) {
     if (this.isPluginEnabled) {
       const growiPlugin = window.growiPlugin;
       const growiPlugin = window.growiPlugin;
@@ -109,7 +116,7 @@ export default class AppContainer extends Container {
       throw new Error('The specified instance must not be null');
       throw new Error('The specified instance must not be null');
     }
     }
 
 
-    const className = instance.constructor.name;
+    const className = instance.constructor.getClassName();
 
 
     if (this.containerInstances[className] != null) {
     if (this.containerInstances[className] != null) {
       throw new Error('The specified instance couldn\'t register because the same type object has already been registered');
       throw new Error('The specified instance couldn\'t register because the same type object has already been registered');
@@ -131,28 +138,27 @@ export default class AppContainer extends Container {
 
 
   /**
   /**
    * Register React component instance
    * Register React component instance
+   * @param {string} id
    * @param {object} instance React component instance
    * @param {object} instance React component instance
    */
    */
-  registerComponentInstance(instance) {
+  registerComponentInstance(id, instance) {
     if (instance == null) {
     if (instance == null) {
       throw new Error('The specified instance must not be null');
       throw new Error('The specified instance must not be null');
     }
     }
 
 
-    const className = instance.constructor.name;
-
-    if (this.componentInstances[className] != null) {
-      throw new Error('The specified instance couldn\'t register because the same type object has already been registered');
+    if (this.componentInstances[id] != null) {
+      throw new Error('The specified instance couldn\'t register because the same id has already been registered');
     }
     }
 
 
-    this.componentInstances[className] = instance;
+    this.componentInstances[id] = instance;
   }
   }
 
 
   /**
   /**
    * Get registered React component instance
    * Get registered React component instance
-   * @param {string} className
+   * @param {string} id
    */
    */
-  getComponentInstance(className) {
-    return this.componentInstances[className];
+  getComponentInstance(id) {
+    return this.componentInstances[id];
   }
   }
 
 
   getOriginRenderer() {
   getOriginRenderer() {
@@ -177,14 +183,6 @@ export default class AppContainer extends Container {
     return renderer;
     return renderer;
   }
   }
 
 
-  setIsDocSaved(isSaved) {
-    this.isDocSaved = isSaved;
-  }
-
-  getIsDocSaved() {
-    return this.isDocSaved;
-  }
-
   getEmojiStrategy() {
   getEmojiStrategy() {
     return emojiStrategy;
     return emojiStrategy;
   }
   }
@@ -270,35 +268,6 @@ export default class AppContainer extends Container {
     return null;
     return null;
   }
   }
 
 
-  createPage(pagePath, markdown, additionalParams = {}) {
-    const params = Object.assign(additionalParams, {
-      path: pagePath,
-      body: markdown,
-    });
-    return this.apiPost('/pages.create', params)
-      .then((res) => {
-        if (!res.ok) {
-          throw new Error(res.error);
-        }
-        return { page: res.page, tags: res.tags };
-      });
-  }
-
-  updatePage(pageId, revisionId, markdown, additionalParams = {}) {
-    const params = Object.assign(additionalParams, {
-      page_id: pageId,
-      revision_id: revisionId,
-      body: markdown,
-    });
-    return this.apiPost('/pages.update', params)
-      .then((res) => {
-        if (!res.ok) {
-          throw new Error(res.error);
-        }
-        return { page: res.page, tags: res.tags };
-      });
-  }
-
   launchHandsontableModal(componentKind, beginLineNumber, endLineNumber) {
   launchHandsontableModal(componentKind, beginLineNumber, endLineNumber) {
     let targetComponent;
     let targetComponent;
     switch (componentKind) {
     switch (componentKind) {

+ 7 - 0
src/client/js/services/CommentContainer.js

@@ -36,6 +36,13 @@ export default class CommentContainer extends Container {
     this.retrieveComments = this.retrieveComments.bind(this);
     this.retrieveComments = this.retrieveComments.bind(this);
   }
   }
 
 
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'CommentContainer';
+  }
+
   getPageContainer() {
   getPageContainer() {
     return this.appContainer.getContainer('PageContainer');
     return this.appContainer.getContainer('PageContainer');
   }
   }

+ 28 - 0
src/client/js/services/EditorContainer.js

@@ -37,6 +37,8 @@ export default class EditorContainer extends Container {
       previewOptions: {},
       previewOptions: {},
     };
     };
 
 
+    this.isSetBeforeunloadEventHandler = false;
+
     this.initStateGrant();
     this.initStateGrant();
     this.initDrafts();
     this.initDrafts();
 
 
@@ -44,6 +46,13 @@ export default class EditorContainer extends Container {
     this.initEditorOptions('previewOptions', 'previewOptions', defaultPreviewOptions);
     this.initEditorOptions('previewOptions', 'previewOptions', defaultPreviewOptions);
   }
   }
 
 
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'EditorContainer';
+  }
+
   /**
   /**
    * initialize state for page permission
    * initialize state for page permission
    */
    */
@@ -129,6 +138,7 @@ export default class EditorContainer extends Container {
       isSlackEnabled: this.state.isSlackEnabled,
       isSlackEnabled: this.state.isSlackEnabled,
       slackChannels: this.state.slackChannels,
       slackChannels: this.state.slackChannels,
       grant: this.state.grant,
       grant: this.state.grant,
+      pageTags: this.state.tags,
     };
     };
 
 
     if (this.state.grantGroupId != null) {
     if (this.state.grantGroupId != null) {
@@ -138,6 +148,24 @@ export default class EditorContainer extends Container {
     return opt;
     return opt;
   }
   }
 
 
+  showUnsavedWarning(e) {
+    // display browser default message
+    e.returnValue = '';
+    return '';
+  }
+
+  disableUnsavedWarning() {
+    window.removeEventListener('beforeunload', this.showUnsavedWarning);
+    this.isSetBeforeunloadEventHandler = false;
+  }
+
+  enableUnsavedWarning() {
+    if (!this.isSetBeforeunloadEventHandler) {
+      window.addEventListener('beforeunload', this.showUnsavedWarning);
+      this.isSetBeforeunloadEventHandler = true;
+    }
+  }
+
   clearDraft(path) {
   clearDraft(path) {
     delete this.drafts[path];
     delete this.drafts[path];
     window.localStorage.setItem('drafts', JSON.stringify(this.drafts));
     window.localStorage.setItem('drafts', JSON.stringify(this.drafts));

+ 189 - 1
src/client/js/services/PageContainer.js

@@ -3,6 +3,7 @@ import { Container } from 'unstated';
 import loggerFactory from '@alias/logger';
 import loggerFactory from '@alias/logger';
 
 
 import * as entities from 'entities';
 import * as entities from 'entities';
+import * as toastr from 'toastr';
 
 
 const logger = loggerFactory('growi:services:PageContainer');
 const logger = loggerFactory('growi:services:PageContainer');
 
 
@@ -40,7 +41,7 @@ export default class PageContainer extends Container {
       likerUserIds: [],
       likerUserIds: [],
 
 
       tags: [],
       tags: [],
-      templateTagData: mainContent.getAttribute('data-template-tags') || '',
+      templateTagData: mainContent.getAttribute('data-template-tags'),
 
 
       // latest(on remote) information
       // latest(on remote) information
       remoteRevisionId: revisionId,
       remoteRevisionId: revisionId,
@@ -54,10 +55,18 @@ export default class PageContainer extends Container {
     this.initStateMarkdown();
     this.initStateMarkdown();
     this.initStateOthers();
     this.initStateOthers();
 
 
+    this.save = this.save.bind(this);
     this.addWebSocketEventHandlers = this.addWebSocketEventHandlers.bind(this);
     this.addWebSocketEventHandlers = this.addWebSocketEventHandlers.bind(this);
     this.addWebSocketEventHandlers();
     this.addWebSocketEventHandlers();
   }
   }
 
 
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'PageContainer';
+  }
+
   /**
   /**
    * initialize state for markdown data
    * initialize state for markdown data
    */
    */
@@ -101,6 +110,185 @@ export default class PageContainer extends Container {
     });
     });
   }
   }
 
 
+
+  /**
+   * save success handler
+   * @param {object} page Page instance
+   * @param {Array[Tag]} tags Array of Tag
+   */
+  updateStateAfterSave(page, tags) {
+    const { editorMode } = this.appContainer.state;
+
+    // update state of PageContainer
+    const newState = {
+      pageId: page._id,
+      revisionId: page.revision._id,
+      revisionCreatedAt: new Date(page.revision.createdAt).getTime() / 1000,
+      remoteRevisionId: page.revision._id,
+      revisionIdHackmdSynced: page.revisionHackmdSynced,
+      hasDraftOnHackmd: page.hasDraftOnHackmd,
+      markdown: page.revision.body,
+    };
+    if (tags != null) {
+      newState.tags = tags;
+    }
+    this.setState(newState);
+
+    // PageEditor component
+    const pageEditor = this.appContainer.getComponentInstance('PageEditor');
+    if (pageEditor != null) {
+      if (editorMode !== 'builtin') {
+        pageEditor.updateEditorValue(newState.markdown);
+      }
+    }
+    // PageEditorByHackmd component
+    const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
+    if (pageEditorByHackmd != null) {
+      // reset
+      if (editorMode !== 'hackmd') {
+        pageEditorByHackmd.reset();
+      }
+    }
+
+    // hidden input
+    $('input[name="revision_id"]').val(newState.revisionId);
+  }
+
+  /**
+   * Save page
+   * @param {string} markdown
+   * @param {object} optionsToSave
+   * @return {object} { page: Page, tags: Tag[] }
+   */
+  async save(markdown, optionsToSave = {}) {
+    const { editorMode } = this.appContainer.state;
+
+    const { pageId, path } = this.state;
+    let { revisionId } = this.state;
+
+    const options = Object.assign({}, optionsToSave);
+
+    if (editorMode === 'hackmd') {
+      // set option to sync
+      options.isSyncRevisionToHackmd = true;
+      revisionId = this.state.revisionIdHackmdSynced;
+    }
+
+    let res;
+    if (pageId == null) {
+      res = await this.createPage(path, markdown, options);
+    }
+    else {
+      res = await this.updatePage(pageId, revisionId, markdown, options);
+    }
+
+    this.updateStateAfterSave(res.page, res.tags);
+    return res;
+  }
+
+  async saveAndReload(optionsToSave) {
+    if (optionsToSave == null) {
+      const msg = '\'saveAndReload\' requires the \'optionsToSave\' param';
+      throw new Error(msg);
+    }
+
+    const { editorMode } = this.appContainer.state;
+    if (editorMode == null) {
+      logger.warn('\'saveAndReload\' requires the \'errorMode\' param');
+      return;
+    }
+
+    const { pageId, path } = this.state;
+    let { revisionId } = this.state;
+
+    const options = Object.assign({}, optionsToSave);
+
+    let markdown;
+    if (editorMode === 'hackmd') {
+      const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
+      markdown = await pageEditorByHackmd.getMarkdown();
+      // set option to sync
+      options.isSyncRevisionToHackmd = true;
+      revisionId = this.state.revisionIdHackmdSynced;
+    }
+    else {
+      const pageEditor = this.appContainer.getComponentInstance('PageEditor');
+      markdown = pageEditor.getMarkdown();
+    }
+
+    let res;
+    if (pageId == null) {
+      res = await this.createPage(path, markdown, options);
+    }
+    else {
+      res = await this.updatePage(pageId, revisionId, markdown, options);
+    }
+
+    const editorContainer = this.appContainer.getContainer('EditorContainer');
+    editorContainer.clearDraft(path);
+    window.location.href = path;
+
+    return res;
+  }
+
+  async createPage(pagePath, markdown, tmpParams) {
+    const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
+
+    // clone
+    const params = Object.assign(tmpParams, {
+      socketClientId: websocketContainer.getSocketClientId(),
+      path: pagePath,
+      body: markdown,
+    });
+
+    const res = await this.appContainer.apiPost('/pages.create', params);
+    if (!res.ok) {
+      throw new Error(res.error);
+    }
+    return { page: res.page, tags: res.tags };
+  }
+
+  async updatePage(pageId, revisionId, markdown, tmpParams) {
+    const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
+
+    // clone
+    const params = Object.assign(tmpParams, {
+      socketClientId: websocketContainer.getSocketClientId(),
+      page_id: pageId,
+      revision_id: revisionId,
+      body: markdown,
+    });
+
+    const res = await this.appContainer.apiPost('/pages.update', params);
+    if (!res.ok) {
+      throw new Error(res.error);
+    }
+    return { page: res.page, tags: res.tags };
+  }
+
+  showSuccessToastr() {
+    toastr.success(undefined, 'Saved successfully', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '1200',
+      extendedTimeOut: '150',
+    });
+  }
+
+  showErrorToastr(error) {
+    toastr.error(error.message, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
+    });
+  }
+
   addWebSocketEventHandlers() {
   addWebSocketEventHandlers() {
     const pageContainer = this;
     const pageContainer = this;
     const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
     const websocketContainer = this.appContainer.getContainer('WebsocketContainer');

+ 8 - 1
src/client/js/services/TagContainer.js

@@ -19,6 +19,13 @@ export default class TagContainer extends Container {
     this.init();
     this.init();
   }
   }
 
 
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'TagContainer';
+  }
+
   /**
   /**
    * retrieve tags data
    * retrieve tags data
    * !! This method should be invoked after PageContainer and EditorContainer has been initialized !!
    * !! This method should be invoked after PageContainer and EditorContainer has been initialized !!
@@ -34,7 +41,7 @@ export default class TagContainer extends Container {
 
 
     const { pageId, templateTagData } = pageContainer.state;
     const { pageId, templateTagData } = pageContainer.state;
 
 
-    let tags;
+    let tags = [];
     // when the page exists
     // when the page exists
     if (pageId != null) {
     if (pageId != null) {
       const res = await this.appContainer.apiGet('/pages.getPageTag', { pageId });
       const res = await this.appContainer.apiGet('/pages.getPageTag', { pageId });

+ 7 - 0
src/client/js/services/WebsocketContainer.js

@@ -22,6 +22,13 @@ export default class WebsocketContainer extends Container {
 
 
   }
   }
 
 
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'WebsocketContainer';
+  }
+
   getWebSocket() {
   getWebSocket() {
     return this.socket;
     return this.socket;
   }
   }

+ 0 - 2
src/client/js/util/GrowiRenderer.js

@@ -3,7 +3,6 @@ import MarkdownIt from 'markdown-it';
 import Linker from './PreProcessor/Linker';
 import Linker from './PreProcessor/Linker';
 import CsvToTable from './PreProcessor/CsvToTable';
 import CsvToTable from './PreProcessor/CsvToTable';
 import XssFilter from './PreProcessor/XssFilter';
 import XssFilter from './PreProcessor/XssFilter';
-import CrowiTemplate from './PostProcessor/CrowiTemplate';
 
 
 import EmojiConfigurer from './markdown-it/emoji';
 import EmojiConfigurer from './markdown-it/emoji';
 import FooternoteConfigurer from './markdown-it/footernote';
 import FooternoteConfigurer from './markdown-it/footernote';
@@ -42,7 +41,6 @@ export default class GrowiRenderer {
         new XssFilter(appContainer),
         new XssFilter(appContainer),
       ];
       ];
       this.postProcessors = [
       this.postProcessors = [
-        new CrowiTemplate(appContainer),
       ];
       ];
     }
     }
 
 

+ 0 - 0
src/client/js/util/PostProcessor/.keep


+ 0 - 85
src/client/js/util/PostProcessor/CrowiTemplate.js

@@ -1,85 +0,0 @@
-import dateFnsFormat from 'date-fns/format';
-
-export default class CrowiTemplate {
-
-  constructor(crowi) {
-    this.crowi = crowi;
-
-    this.getUser = this.getUser.bind(this);
-
-    this.templatePattern = {
-      year: this.getYear,
-      month: this.getMonth,
-      date: this.getDate,
-      user: this.getUser,
-    };
-  }
-
-  process(markdown) {
-    // see: https://regex101.com/r/WR6IvX/3
-    return markdown.replace(/:::\s*(\S+)[\r\n]((.|[\r\n])*?)[\r\n]:::/gm, (all, group1, group2) => {
-      const lang = group1;
-      let code = group2;
-
-      if (!lang.match(/^template/)) {
-        return all;
-      }
-
-      const templateId = new Date().getTime().toString(16) + Math.floor(1000 * Math.random()).toString(16);
-      let pageName = lang;
-      if (lang.match(':')) {
-        pageName = this.parseTemplateString(lang.split(':')[1]);
-      }
-      code = this.parseTemplateString(code);
-
-      return (
-        /* eslint-disable quotes */
-        `<div class="page-template-builder">`
-          + `<button class="template-create-button btn btn-default" data-template="${templateId}" data-path="${pageName}">`
-            + `<i class="fa fa-pencil"></i> ${pageName}`
-          + `</button>`
-          + `<pre><code id="${templateId}" class="lang-${lang}">${code}\n</code></pre>`
-        + `</div>`
-        /* eslint-enable quotes */
-      );
-    });
-  }
-
-  getYear() {
-    return dateFnsFormat(new Date(), 'YYYY');
-  }
-
-  getMonth() {
-    return dateFnsFormat(new Date(), 'YYYY/MM');
-  }
-
-  getDate() {
-    return dateFnsFormat(new Date(), 'YYYY/MM/DD');
-  }
-
-  getUser() {
-    const username = this.crowi.me || null;
-
-    if (!username) {
-      return '';
-    }
-
-    return `/user/${username}`;
-  }
-
-  parseTemplateString(templateString) {
-    let parsed = templateString;
-
-    Object.keys(this.templatePattern).forEach((key) => {
-      const k = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-      const matcher = new RegExp(`{${k}}`, 'g');
-      if (parsed.match(matcher)) {
-        const replacer = this.templatePattern[key]();
-        parsed = parsed.replace(matcher, replacer);
-      }
-    });
-
-    return parsed;
-  }
-
-}

+ 56 - 46
src/client/styles/scss/_comment.scss

@@ -1,46 +1,56 @@
-.main-container {
-  .page-comment-main {
-    // delete button
-    .page-comment-control {
-      position: absolute;
-      top: 0;
-      right: 0;
-      display: none; // default hidden
-    }
-  }
-
-  // modal
-  .page-comment-delete-modal .modal-content {
-    .modal-body {
-      .comment-body {
-        max-height: 13em;
-        // scrollable
-        overflow-y: auto;
-      }
-    }
-  }
-}
-
-.main-container {
-  .page-comments {
-    .page-comments-list-toggle-newer,
-    .page-comments-list-toggle-older {
-      display: block;
-      margin: 8px;
-      font-size: 0.9em;
-      text-align: center;
-    }
-
-    // older comments
-    .page-comments-list-older .page-comment {
-    }
-    // newer comments
-    .page-comments-list-newer .page-comment {
-      opacity: 0.7;
-
-      &:hover {
-        opacity: 1;
-      }
-    }
-  }
-}
+.main-container {
+  .page-comment-main {
+    // delete button
+    .page-comment-control {
+      position: absolute;
+      top: 0;
+      right: 0;
+      display: none; // default hidden
+    }
+  }
+
+  // modal
+  .page-comment-delete-modal .modal-content {
+    .modal-body {
+      .comment-body {
+        max-height: 13em;
+        // scrollable
+        overflow-y: auto;
+      }
+    }
+  }
+}
+
+.main-container {
+  .page-comments {
+    .page-comments-list-toggle-newer,
+    .page-comments-list-toggle-older {
+      display: block;
+      margin: 8px;
+      font-size: 0.9em;
+      text-align: center;
+    }
+
+    // older comments
+    .page-comments-list-older .page-comment {
+    }
+    // newer comments
+    .page-comments-list-newer .page-comment {
+      opacity: 0.7;
+
+      &:hover {
+        opacity: 1;
+      }
+    }
+  }
+}
+
+.btn-xxs {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 50px;
+  height: 10px;
+  font-size: 11px;
+  border-radius: 1px;
+}

+ 93 - 0
src/client/styles/scss/_staff_credit.scss

@@ -0,0 +1,93 @@
+// Staff Credit
+#staff-credit {
+  // see https://css-tricks.com/old-timey-terminal-styling/
+  @mixin old-timey-terminal-styling() {
+    text-shadow: 0 0 10px #c8c8c8;
+    background-color: black;
+    background-image: radial-gradient(rgba(50, 100, 100, 0.75), black 120%);
+    &::after {
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 100vw;
+      height: 100vh;
+      content: '';
+      background: repeating-linear-gradient(0deg, rgba(black, 0.15), rgba(black, 0.15) 2px, transparent 2px, transparent 4px);
+    }
+  }
+
+  font-family: 'Press Start 2P', $basefont1;
+  color: white;
+
+  h1,
+  h2,
+  h3,
+  h4,
+  h5,
+  h6 {
+    font-family: 'Press Start 2P', $basefont1;
+    color: white;
+  }
+
+  $credit-length: -200em;
+
+  // see https://css-tricks.com/old-timey-terminal-styling/
+  .credit-curtain {
+    position: fixed;
+    top: 10vh;
+    left: 20vh;
+    width: 80vw;
+    height: 80vh;
+    overflow-y: hidden;
+
+    @include old-timey-terminal-styling();
+  }
+
+  .credit-body {
+    position: relative;
+    top: $credit-length;
+    animation-name: Credit;
+    // credit duration
+    animation-duration: 20s;
+    animation-timing-function: linear;
+  }
+
+  @keyframes Credit {
+    from {
+      top: 100%;
+    }
+    to {
+      // credit length
+      top: $credit-length;
+    }
+  }
+
+  h1 {
+    font-size: 3em;
+  }
+
+  h2 {
+    font-size: 2.2em;
+  }
+
+  .dev-position {
+    font-size: 1em;
+  }
+
+  .dev-name {
+    font-size: 1.8em;
+  }
+
+  .staff-credit-mt-10 {
+    margin-top: 6rem;
+  }
+
+  .staff-credit-mb-10 {
+    margin-bottom: 6rem;
+  }
+
+  .staff-credit-my-10 {
+    @extend .staff-credit-mt-10;
+    @extend .staff-credit-mb-10;
+  }
+}

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

@@ -39,7 +39,9 @@
 @import 'user_growi';
 @import 'user_growi';
 @import 'handsontable';
 @import 'handsontable';
 @import 'wiki';
 @import 'wiki';
+@import 'staff_credit';
 @import 'tag';
 @import 'tag';
+@import 'staff_credit';
 @import 'draft';
 @import 'draft';
 
 
 /*
 /*

+ 17 - 3
src/server/crowi/express-init.js

@@ -9,7 +9,7 @@ module.exports = function(crowi, app) {
   const cookieParser = require('cookie-parser');
   const cookieParser = require('cookie-parser');
   const methodOverride = require('method-override');
   const methodOverride = require('method-override');
   const passport = require('passport');
   const passport = require('passport');
-  const session = require('express-session');
+  const expressSession = require('express-session');
   const sanitizer = require('express-sanitizer');
   const sanitizer = require('express-sanitizer');
   const basicAuth = require('basic-auth-connect');
   const basicAuth = require('basic-auth-connect');
   const flash = require('connect-flash');
   const flash = require('connect-flash');
@@ -19,10 +19,13 @@ module.exports = function(crowi, app) {
   const i18nFsBackend = require('i18next-node-fs-backend');
   const i18nFsBackend = require('i18next-node-fs-backend');
   const i18nSprintf = require('i18next-sprintf-postprocessor');
   const i18nSprintf = require('i18next-sprintf-postprocessor');
   const i18nMiddleware = require('i18next-express-middleware');
   const i18nMiddleware = require('i18next-express-middleware');
+
+  const avoidSessionRoutes = require('../routes/avoid-session-routes');
   const i18nUserSettingDetector = require('../util/i18nUserSettingDetector');
   const i18nUserSettingDetector = require('../util/i18nUserSettingDetector');
-  const env = crowi.node_env;
   const middleware = require('../util/middlewares');
   const middleware = require('../util/middlewares');
 
 
+  const env = crowi.node_env;
+
   // Old type config API
   // Old type config API
   const config = crowi.getConfig();
   const config = crowi.getConfig();
   const Config = crowi.model('Config');
   const Config = crowi.model('Config');
@@ -102,7 +105,18 @@ module.exports = function(crowi, app) {
   app.use(bodyParser.json({ limit: '50mb' }));
   app.use(bodyParser.json({ limit: '50mb' }));
   app.use(sanitizer());
   app.use(sanitizer());
   app.use(cookieParser());
   app.use(cookieParser());
-  app.use(session(crowi.sessionConfig));
+
+  // configure express-session
+  app.use((req, res, next) => {
+    // test whether the route is listed in avoidSessionTroutes
+    for (const regex of avoidSessionRoutes) {
+      if (regex.test(req.path)) {
+        return next();
+      }
+    }
+
+    expressSession(crowi.sessionConfig)(req, res, next);
+  });
 
 
   // Set basic auth middleware
   // Set basic auth middleware
   app.use((req, res, next) => {
   app.use((req, res, next) => {

+ 1 - 0
src/server/crowi/index.js

@@ -275,6 +275,7 @@ Crowi.prototype.setupPassport = function() {
     this.passportService.setupGoogleStrategy();
     this.passportService.setupGoogleStrategy();
     this.passportService.setupGitHubStrategy();
     this.passportService.setupGitHubStrategy();
     this.passportService.setupTwitterStrategy();
     this.passportService.setupTwitterStrategy();
+    this.passportService.setupOidcStrategy();
     this.passportService.setupSamlStrategy();
     this.passportService.setupSamlStrategy();
   }
   }
   catch (err) {
   catch (err) {

+ 17 - 0
src/server/form/admin/securityPassportOidc.js

@@ -0,0 +1,17 @@
+const form = require('express-form');
+
+const field = form.field;
+
+module.exports = form(
+  field('settingForm[security:passport-oidc:isEnabled]').trim().toBooleanStrict().required(),
+  field('settingForm[security:passport-oidc:providerName]').trim(),
+  field('settingForm[security:passport-oidc:issuerHost]').trim(),
+  field('settingForm[security:passport-oidc:clientId]').trim(),
+  field('settingForm[security:passport-oidc:clientSecret]').trim(),
+  field('settingForm[security:passport-oidc:attrMapId]').trim(),
+  field('settingForm[security:passport-oidc:attrMapUserName]').trim(),
+  field('settingForm[security:passport-oidc:attrMapName]').trim(),
+  field('settingForm[security:passport-oidc:attrMapMail]').trim(),
+  field('settingForm[security:passport-oidc:isSameEmailTreatedAsIdenticalUser]').trim().toBooleanStrict(),
+  field('settingForm[security:passport-oidc:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
+);

+ 1 - 0
src/server/form/index.js

@@ -26,6 +26,7 @@ module.exports = {
     securityPassportGoogle: require('./admin/securityPassportGoogle'),
     securityPassportGoogle: require('./admin/securityPassportGoogle'),
     securityPassportGitHub: require('./admin/securityPassportGitHub'),
     securityPassportGitHub: require('./admin/securityPassportGitHub'),
     securityPassportTwitter: require('./admin/securityPassportTwitter'),
     securityPassportTwitter: require('./admin/securityPassportTwitter'),
+    securityPassportOidc: require('./admin/securityPassportOidc'),
     markdown: require('./admin/markdown'),
     markdown: require('./admin/markdown'),
     markdownXss: require('./admin/markdownXss'),
     markdownXss: require('./admin/markdownXss'),
     markdownPresentation: require('./admin/markdownPresentation'),
     markdownPresentation: require('./admin/markdownPresentation'),

+ 8 - 0
src/server/models/comment.js

@@ -78,6 +78,14 @@ module.exports = function(crowi) {
     }));
     }));
   };
   };
 
 
+  commentSchema.methods.removeWithReplies = async function() {
+    const Comment = crowi.model('Comment');
+    return Comment.remove({
+      $or: (
+        [{ replyTo: this._id }, { _id: this._id }]),
+    });
+  };
+
   /**
   /**
    * post save hook
    * post save hook
    */
    */

+ 6 - 0
src/server/models/config.js

@@ -84,6 +84,7 @@ module.exports = function(crowi) {
       'security:passport-google:isEnabled' : false,
       'security:passport-google:isEnabled' : false,
       'security:passport-github:isEnabled' : false,
       'security:passport-github:isEnabled' : false,
       'security:passport-twitter:isEnabled' : false,
       'security:passport-twitter:isEnabled' : false,
+      'security:passport-oidc:isEnabled' : false,
 
 
       'aws:bucket'          : 'growi',
       'aws:bucket'          : 'growi',
       'aws:region'          : 'ap-northeast-1',
       'aws:region'          : 'ap-northeast-1',
@@ -341,6 +342,11 @@ module.exports = function(crowi) {
     return getValueForCrowiNS(config, key);
     return getValueForCrowiNS(config, key);
   };
   };
 
 
+  configSchema.statics.isEnabledPassportOidc = function(config) {
+    const key = 'security:passport-oidc:isEnabled';
+    return getValueForCrowiNS(config, key);
+  };
+
   configSchema.statics.isUploadable = function(config) {
   configSchema.statics.isUploadable = function(config) {
     const method = process.env.FILE_UPLOAD || 'aws';
     const method = process.env.FILE_UPLOAD || 'aws';
 
 

+ 0 - 1
src/server/models/index.js

@@ -1,6 +1,5 @@
 module.exports = {
 module.exports = {
   Page: require('./page'),
   Page: require('./page'),
-  PageGroupRelation: require('./page-group-relation'),
   PageTagRelation: require('./page-tag-relation'),
   PageTagRelation: require('./page-tag-relation'),
   User: require('./user'),
   User: require('./user'),
   ExternalAccount: require('./external-account'),
   ExternalAccount: require('./external-account'),

+ 0 - 262
src/server/models/page-group-relation.js

@@ -1,262 +0,0 @@
-// disable no-return-await for model functions
-/* eslint-disable no-return-await */
-
-const debug = require('debug')('growi:models:pageGroupRelation');
-const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate');
-
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
-/*
- * define schema
- */
-const schema = new mongoose.Schema({
-  relatedGroup: { type: ObjectId, ref: 'UserGroup', required: true },
-  targetPage: { type: ObjectId, ref: 'Page', required: true },
-  createdAt: { type: Date, default: Date.now },
-}, {
-  toJSON: { getters: true },
-  toObject: { getters: true },
-});
-// apply plugins
-schema.plugin(mongoosePaginate);
-
-
-/**
- * PageGroupRelation Class
- *
- * @class PageGroupRelation
- */
-class PageGroupRelation {
-
-  /**
-   * limit items num for pagination
-   *
-   * @readonly
-   * @static
-   * @memberof PageGroupRelation
-   */
-  static get PAGE_ITEMS() {
-    return 50;
-  }
-
-  static set crowi(crowi) {
-    this._crowi = crowi;
-  }
-
-  static get crowi() {
-    return this._crowi;
-  }
-
-  static init() {
-    this.removeAllInvalidRelations();
-  }
-
-  /**
-   * remove all invalid relations that has reference to unlinked document
-   */
-  static removeAllInvalidRelations() {
-    return this.findAllRelation()
-      .then((relations) => {
-        // filter invalid documents
-        return relations.filter((relation) => {
-          return relation.targetPage == null || relation.relatedGroup == null;
-        });
-      })
-      .then((invalidRelations) => {
-        const ids = invalidRelations.map((relation) => { return relation._id });
-        return this.deleteMany({ _id: { $in: ids } });
-      });
-  }
-
-  /**
-   * find all page and group relation
-   *
-   * @static
-   * @returns {Promise<PageGroupRelation[]>}
-   * @memberof PageGroupRelation
-   */
-  static findAllRelation() {
-    return this
-      .find()
-      .populate('targetPage')
-      .populate('relatedGroup')
-      .exec();
-  }
-
-  /**
-   * find all page and group relation for UserGroup
-   *
-   * @static
-   * @param {UserGroup} userGroup
-   * @returns {Promise<PageGroupRelation[]>}
-   * @memberof PageGroupRelation
-   */
-  static findAllRelationForUserGroup(userGroup) {
-    debug('findAllRelationForUserGroup is called', userGroup);
-
-    return this
-      .find({ relatedGroup: userGroup.id })
-      .populate('targetPage')
-      .exec();
-  }
-
-  /**
-   * find all entities with pagination
-   *
-   * @see https://github.com/edwardhotchkiss/mongoose-paginate
-   *
-   * @static
-   * @param {UserGroup} userGroup
-   * @param {any} opts mongoose-paginate options object
-   * @returns {Promise<any>} mongoose-paginate result object
-   * @memberof UserGroupRelation
-   */
-  // static findPageGroupRelationsWithPagination(userGroup, opts) {
-  //   const query = { relatedGroup: userGroup };
-  //   const options = Object.assign({}, opts);
-  //   if (options.page == null) {
-  //     options.page = 1;
-  //   }
-  //   if (options.limit == null) {
-  //     options.limit = UserGroupRelation.PAGE_ITEMS;
-  //   }
-
-  //   return this.paginate(query, options);
-  // }
-
-  /**
-   * find the relation or create(if not exists) for page and group
-   *
-   * @static
-   * @param {Page} page
-   * @param {UserGroup} userGroup
-   * @returns {Promise<PageGroupRelation>}
-   * @memberof PageGroupRelation
-   */
-  static findOrCreateRelationForPageAndGroup(page, userGroup) {
-    const query = { targetPage: page.id, relatedGroup: userGroup.id };
-
-    return this
-      .count(query)
-      .then((count) => {
-        // return (0 < count);
-        if (count > 0) {
-          return this.find(query).exec();
-        }
-
-        return this.createRelation(userGroup, page);
-      });
-  }
-
-  /**
-   * find page and group relation for Page
-   *
-   * @static
-   * @param {Page} page
-   * @returns {Promise<PageGroupRelation[]>}
-   * @memberof PageGroupRelation
-   */
-  static findByPage(page) {
-    if (page == null) {
-      return null;
-    }
-    return this
-      .findOne({ targetPage: page.id })
-      .populate('relatedGroup')
-      .exec();
-  }
-
-  /**
-   * get is exists granted group for relatedPage and relatedUser
-   *
-   * @static
-   * @param {any} pageData relatedPage
-   * @param {any} userData relatedUser
-   * @returns is exists granted group(or not)
-   * @memberof PageGroupRelation
-   */
-  static async isExistsGrantedGroupForPageAndUser(pageData, userData) {
-    const UserGroupRelation = PageGroupRelation.crowi.model('UserGroupRelation');
-
-    const pageRelation = await this.findByPage(pageData);
-    if (pageRelation == null) {
-      return false;
-    }
-    return await UserGroupRelation.isRelatedUserForGroup(userData, pageRelation.relatedGroup);
-  }
-
-  /**
-   * create page and group relation
-   *
-   * @static
-   * @param {any} userGroup
-   * @param {any} page
-   * @returns
-   * @memberof PageGroupRelation
-   */
-  static createRelation(userGroup, page) {
-    return this.create({
-      relatedGroup: userGroup.id,
-      targetPage: page.id,
-    });
-  }
-
-  /**
-   * remove all relation for UserGroup
-   *
-   * @static
-   * @param {UserGroup} userGroup related group for remove
-   * @returns {Promise<any>}
-   * @memberof PageGroupRelation
-   */
-  static removeAllByUserGroup(userGroup) {
-    return this.deleteMany({ relatedGroup: userGroup });
-  }
-
-  /**
-   * remove all relation for Page
-   *
-   * @static
-   * @param {Page} page related page for remove
-   * @returns {Promise<any>}
-   * @memberof PageGroupRelation
-   */
-  static removeAllByPage(page) {
-    return this.findByPage(page)
-      .then((relation) => {
-        if (relation != null) {
-          relation.remove();
-        }
-      });
-  }
-
-  /**
-   * remove relation by id
-   *
-   * @static
-   * @param {ObjectId} id for remove
-   * @returns {Promise<any>}
-   * @memberof PageGroupRelation
-   */
-  static removeById(id) {
-    return this.findById(id)
-      .then((relationData) => {
-        if (relationData == null) {
-          throw new Error('PageGroupRelation data is not exists. id:', id);
-        }
-        else {
-          relationData.remove();
-        }
-      });
-  }
-
-}
-
-module.exports = function(crowi) {
-  PageGroupRelation.crowi = crowi;
-  schema.loadClass(PageGroupRelation);
-  const model = mongoose.model('PageGroupRelation', schema);
-  model.init();
-  return model;
-};

+ 0 - 2
src/server/models/page.js

@@ -1165,7 +1165,6 @@ module.exports = function(crowi) {
     const Attachment = crowi.model('Attachment');
     const Attachment = crowi.model('Attachment');
     const Comment = crowi.model('Comment');
     const Comment = crowi.model('Comment');
     const Revision = crowi.model('Revision');
     const Revision = crowi.model('Revision');
-    const PageGroupRelation = crowi.model('PageGroupRelation');
     const pageId = pageData._id;
     const pageId = pageData._id;
     const socketClientId = options.socketClientId || null;
     const socketClientId = options.socketClientId || null;
 
 
@@ -1177,7 +1176,6 @@ module.exports = function(crowi) {
     await Revision.removeRevisionsByPath(pageData.path);
     await Revision.removeRevisionsByPath(pageData.path);
     await this.findByIdAndRemove(pageId);
     await this.findByIdAndRemove(pageId);
     await this.removeRedirectOriginPageByPath(pageData.path);
     await this.removeRedirectOriginPageByPath(pageData.path);
-    await PageGroupRelation.removeAllByPage(pageData);
     if (socketClientId != null) {
     if (socketClientId != null) {
       pageEvent.emit('delete', pageData, user, socketClientId); // update as renamed page
       pageEvent.emit('delete', pageData, user, socketClientId); // update as renamed page
     }
     }

+ 0 - 2
src/server/models/user-group.js

@@ -91,7 +91,6 @@ class UserGroup {
 
 
   // グループの完全削除
   // グループの完全削除
   static async removeCompletelyById(deleteGroupId, action, selectedGroupId) {
   static async removeCompletelyById(deleteGroupId, action, selectedGroupId) {
-    const PageGroupRelation = mongoose.model('PageGroupRelation');
     const UserGroupRelation = mongoose.model('UserGroupRelation');
     const UserGroupRelation = mongoose.model('UserGroupRelation');
     const Page = mongoose.model('Page');
     const Page = mongoose.model('Page');
 
 
@@ -103,7 +102,6 @@ class UserGroup {
 
 
     await Promise.all([
     await Promise.all([
       UserGroupRelation.removeAllByUserGroup(deletedGroup),
       UserGroupRelation.removeAllByUserGroup(deletedGroup),
-      PageGroupRelation.removeAllByUserGroup(deletedGroup),
       Page.handlePrivatePagesForDeletedGroup(deletedGroup, action, selectedGroupId),
       Page.handlePrivatePagesForDeletedGroup(deletedGroup, action, selectedGroupId),
     ]);
     ]);
 
 

+ 3 - 3
src/server/models/user.js

@@ -227,7 +227,7 @@ module.exports = function(crowi) {
     this.image = undefined;
     this.image = undefined;
 
 
     if (this.imageAttachment != null) {
     if (this.imageAttachment != null) {
-      Attachment.removeWithSubstance(this.imageAttachment._id);
+      Attachment.removeWithSubstanceById(this.imageAttachment._id);
     }
     }
 
 
     this.imageAttachment = undefined;
     this.imageAttachment = undefined;
@@ -336,11 +336,11 @@ module.exports = function(crowi) {
   userSchema.statics.getLanguageLabels = getLanguageLabels;
   userSchema.statics.getLanguageLabels = getLanguageLabels;
   userSchema.statics.getUserStatusLabels = function() {
   userSchema.statics.getUserStatusLabels = function() {
     const userStatus = {};
     const userStatus = {};
-    userStatus[STATUS_REGISTERED] = '承認待ち';
+    userStatus[STATUS_REGISTERED] = 'Approval Pending';
     userStatus[STATUS_ACTIVE] = 'Active';
     userStatus[STATUS_ACTIVE] = 'Active';
     userStatus[STATUS_SUSPENDED] = 'Suspended';
     userStatus[STATUS_SUSPENDED] = 'Suspended';
     userStatus[STATUS_DELETED] = 'Deleted';
     userStatus[STATUS_DELETED] = 'Deleted';
-    userStatus[STATUS_INVITED] = '招待済み';
+    userStatus[STATUS_INVITED] = 'Invited';
 
 
     return userStatus;
     return userStatus;
   };
   };

+ 30 - 2
src/server/routes/admin.js

@@ -25,7 +25,6 @@ module.exports = function(crowi, app) {
   const MAX_PAGE_LIST = 50;
   const MAX_PAGE_LIST = 50;
   const actions = {};
   const actions = {};
 
 
-
   function createPager(total, limit, page, pagesCount, maxPageList) {
   function createPager(total, limit, page, pagesCount, maxPageList) {
     const pager = {
     const pager = {
       page,
       page,
@@ -1059,7 +1058,6 @@ module.exports = function(crowi, app) {
     await saveSettingAsync(form);
     await saveSettingAsync(form);
     const config = await crowi.getConfig();
     const config = await crowi.getConfig();
 
 
-
     // reset strategy
     // reset strategy
     await crowi.passportService.resetTwitterStrategy();
     await crowi.passportService.resetTwitterStrategy();
     // setup strategy
     // setup strategy
@@ -1076,6 +1074,36 @@ module.exports = function(crowi, app) {
 
 
     return res.json({ status: true });
     return res.json({ status: true });
   };
   };
+
+  actions.api.securityPassportOidcSetting = async(req, res) => {
+    const form = req.form.settingForm;
+
+    if (!req.form.isValid) {
+      return res.json({ status: false, message: req.form.errors.join('\n') });
+    }
+
+    debug('form content', form);
+    await saveSettingAsync(form);
+    const config = await crowi.getConfig();
+
+
+    // reset strategy
+    await crowi.passportService.resetOidcStrategy();
+    // setup strategy
+    if (Config.isEnabledPassportOidc(config)) {
+      try {
+        await crowi.passportService.setupOidcStrategy(true);
+      }
+      catch (err) {
+        // reset
+        await crowi.passportService.resetOidcStrategy();
+        return res.json({ status: false, message: err.message });
+      }
+    }
+
+    return res.json({ status: true });
+  };
+
   actions.api.customizeSetting = function(req, res) {
   actions.api.customizeSetting = function(req, res) {
     const form = req.form.settingForm;
     const form = req.form.settingForm;
 
 

+ 29 - 0
src/server/routes/attachment.js

@@ -339,5 +339,34 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success({}));
     return res.json(ApiResponse.success({}));
   };
   };
 
 
+  /**
+   * @api {post} /attachments.removeProfileImage Remove profile image attachments
+   * @apiGroup Attachment
+   * @apiParam {String} attachment_id
+   */
+  api.removeProfileImage = async function(req, res) {
+    const user = req.user;
+    const attachment = await Attachment.findById(user.imageAttachment);
+
+    if (attachment == null) {
+      return res.json(ApiResponse.error('attachment not found'));
+    }
+
+    const isDeletable = await isDeletableByUser(user, attachment);
+    if (!isDeletable) {
+      return res.json(ApiResponse.error(`Forbidden to remove the attachment '${attachment.id}'`));
+    }
+
+    try {
+      await user.deleteImage();
+    }
+    catch (err) {
+      logger.error(err);
+      return res.status(500).json(ApiResponse.error('Error while deleting image'));
+    }
+
+    return res.json(ApiResponse.success({}));
+  };
+
   return actions;
   return actions;
 };
 };

+ 5 - 0
src/server/routes/avoid-session-routes.js

@@ -0,0 +1,5 @@
+module.exports = [
+  /^\/_api\/v3\/healthcheck/,
+  /^\/_hackmd\//,
+  /^\/api-docs\//,
+];

+ 1 - 1
src/server/routes/comment.js

@@ -174,7 +174,7 @@ module.exports = function(crowi, app) {
         throw new Error('Current user is not accessible to this page.');
         throw new Error('Current user is not accessible to this page.');
       }
       }
 
 
-      await comment.remove();
+      await comment.removeWithReplies();
       await Page.updateCommentCount(comment.page);
       await Page.updateCommentCount(comment.page);
     }
     }
     catch (err) {
     catch (err) {

+ 5 - 1
src/server/routes/index.js

@@ -76,13 +76,16 @@ module.exports = function(crowi, app) {
   app.post('/_api/admin/security/passport-google' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportGoogle, admin.api.securityPassportGoogleSetting);
   app.post('/_api/admin/security/passport-google' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportGoogle, admin.api.securityPassportGoogleSetting);
   app.post('/_api/admin/security/passport-github' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportGitHub, admin.api.securityPassportGitHubSetting);
   app.post('/_api/admin/security/passport-github' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportGitHub, admin.api.securityPassportGitHubSetting);
   app.post('/_api/admin/security/passport-twitter', loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportTwitter, admin.api.securityPassportTwitterSetting);
   app.post('/_api/admin/security/passport-twitter', loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportTwitter, admin.api.securityPassportTwitterSetting);
+  app.post('/_api/admin/security/passport-oidc'   , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportOidc, admin.api.securityPassportOidcSetting);
   app.get('/passport/google'                      , loginPassport.loginWithGoogle);
   app.get('/passport/google'                      , loginPassport.loginWithGoogle);
   app.get('/passport/github'                      , loginPassport.loginWithGitHub);
   app.get('/passport/github'                      , loginPassport.loginWithGitHub);
   app.get('/passport/twitter'                     , loginPassport.loginWithTwitter);
   app.get('/passport/twitter'                     , loginPassport.loginWithTwitter);
+  app.get('/passport/oidc'                        , loginPassport.loginWithOidc);
   app.get('/passport/saml'                        , loginPassport.loginWithSaml);
   app.get('/passport/saml'                        , loginPassport.loginWithSaml);
   app.get('/passport/google/callback'             , loginPassport.loginPassportGoogleCallback);
   app.get('/passport/google/callback'             , loginPassport.loginPassportGoogleCallback);
   app.get('/passport/github/callback'             , loginPassport.loginPassportGitHubCallback);
   app.get('/passport/github/callback'             , loginPassport.loginPassportGitHubCallback);
   app.get('/passport/twitter/callback'            , loginPassport.loginPassportTwitterCallback);
   app.get('/passport/twitter/callback'            , loginPassport.loginPassportTwitterCallback);
+  app.get('/passport/oidc/callback'               , loginPassport.loginPassportOidcCallback);
   app.post('/passport/saml/callback'              , loginPassport.loginPassportSamlCallback);
   app.post('/passport/saml/callback'              , loginPassport.loginPassportSamlCallback);
 
 
   // markdown admin
   // markdown admin
@@ -218,9 +221,10 @@ module.exports = function(crowi, app) {
   app.post('/_api/likes.remove'       , accessTokenParser , loginRequired(crowi, app) , csrf, page.api.unlike);
   app.post('/_api/likes.remove'       , accessTokenParser , loginRequired(crowi, app) , csrf, page.api.unlike);
   app.get('/_api/attachments.list'   , accessTokenParser , loginRequired(crowi, app, false) , attachment.api.list);
   app.get('/_api/attachments.list'   , accessTokenParser , loginRequired(crowi, app, false) , attachment.api.list);
   app.post('/_api/attachments.add'                  , uploads.single('file'), autoReap, accessTokenParser, loginRequired(crowi, app) ,csrf, attachment.api.add);
   app.post('/_api/attachments.add'                  , uploads.single('file'), autoReap, accessTokenParser, loginRequired(crowi, app) ,csrf, attachment.api.add);
+  app.post('/_api/attachments.removeProfileImage', accessTokenParser, loginRequired(crowi, app), csrf, attachment.api.removeProfileImage);
   app.post('/_api/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequired(crowi, app) ,csrf, attachment.api.uploadProfileImage);
   app.post('/_api/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequired(crowi, app) ,csrf, attachment.api.uploadProfileImage);
   app.post('/_api/attachments.remove' , accessTokenParser , loginRequired(crowi, app) , csrf, attachment.api.remove);
   app.post('/_api/attachments.remove' , accessTokenParser , loginRequired(crowi, app) , csrf, attachment.api.remove);
-  app.get('/_api/attachments.limit'  , accessTokenParser , loginRequired(crowi, app) , csrf, attachment.api.limit);
+  app.get('/_api/attachments.limit'   , accessTokenParser , loginRequired(crowi, app) , attachment.api.limit);
 
 
   app.get('/_api/revisions.get'      , accessTokenParser , loginRequired(crowi, app, false) , revision.api.get);
   app.get('/_api/revisions.get'      , accessTokenParser , loginRequired(crowi, app, false) , revision.api.get);
   app.get('/_api/revisions.ids'      , accessTokenParser , loginRequired(crowi, app, false) , revision.api.ids);
   app.get('/_api/revisions.ids'      , accessTokenParser , loginRequired(crowi, app, false) , revision.api.ids);

+ 50 - 0
src/server/routes/login-passport.js

@@ -350,6 +350,54 @@ 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');
+      return next();
+    }
+
+    passport.authenticate('oidc')(req, res);
+  };
+
+  const loginPassportOidcCallback = async(req, res, next) => {
+    const providerId = 'oidc';
+    const strategyName = 'oidc';
+    const attrMapId = crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapId');
+    const attrMapUserName = crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapUserName');
+    const attrMapName = crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapName');
+    const attrMapMail = crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapMail');
+
+    let response;
+    try {
+      response = await promisifiedPassportAuthentication(strategyName, req, res);
+    }
+    catch (err) {
+      debug(err);
+      return loginFailure(req, res, next);
+    }
+
+    const userInfo = {
+      id: response[attrMapId],
+      username: response[attrMapUserName],
+      name: response[attrMapName],
+      email: response[attrMapMail],
+    };
+    debug('mapping response to userInfo', userInfo, response, attrMapId, attrMapUserName, attrMapMail);
+
+    const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
+    if (!externalAccount) {
+      return loginFailure(req, res, next);
+    }
+
+    // login
+    const user = await externalAccount.getPopulatedUser();
+    req.logIn(user, (err) => {
+      if (err) { return next(err) }
+      return loginSuccess(req, res, user);
+    });
+  };
+
   const loginWithSaml = function(req, res, next) {
   const loginWithSaml = function(req, res, next) {
     if (!passportService.isSamlStrategySetup) {
     if (!passportService.isSamlStrategySetup) {
       debug('SamlStrategy has not been set up');
       debug('SamlStrategy has not been set up');
@@ -480,10 +528,12 @@ module.exports = function(crowi, app) {
     loginWithGoogle,
     loginWithGoogle,
     loginWithGitHub,
     loginWithGitHub,
     loginWithTwitter,
     loginWithTwitter,
+    loginWithOidc,
     loginWithSaml,
     loginWithSaml,
     loginPassportGoogleCallback,
     loginPassportGoogleCallback,
     loginPassportGitHubCallback,
     loginPassportGitHubCallback,
     loginPassportTwitterCallback,
     loginPassportTwitterCallback,
+    loginPassportOidcCallback,
     loginPassportSamlCallback,
     loginPassportSamlCallback,
   };
   };
 };
 };

+ 71 - 0
src/server/service/passport.js

@@ -6,7 +6,9 @@ const LdapStrategy = require('passport-ldapauth');
 const GoogleStrategy = require('passport-google-auth').Strategy;
 const GoogleStrategy = require('passport-google-auth').Strategy;
 const GitHubStrategy = require('passport-github').Strategy;
 const GitHubStrategy = require('passport-github').Strategy;
 const TwitterStrategy = require('passport-twitter').Strategy;
 const TwitterStrategy = require('passport-twitter').Strategy;
+const OidcStrategy = require('openid-client').Strategy;
 const SamlStrategy = require('passport-saml').Strategy;
 const SamlStrategy = require('passport-saml').Strategy;
+const OIDCIssuer = require('openid-client').Issuer;
 
 
 /**
 /**
  * the service class of Passport
  * the service class of Passport
@@ -46,6 +48,11 @@ class PassportService {
      */
      */
     this.isTwitterStrategySetup = false;
     this.isTwitterStrategySetup = false;
 
 
+    /**
+     * the flag whether OidcStrategy is set up successfully
+     */
+    this.isOidcStrategySetup = false;
+
     /**
     /**
      * the flag whether SamlStrategy is set up successfully
      * the flag whether SamlStrategy is set up successfully
      */
      */
@@ -454,6 +461,70 @@ class PassportService {
     this.isTwitterStrategySetup = false;
     this.isTwitterStrategySetup = false;
   }
   }
 
 
+  async setupOidcStrategy() {
+    // check whether the strategy has already been set up
+    if (this.isOidcStrategySetup) {
+      throw new Error('OidcStrategy has already been set up');
+    }
+
+    const config = this.crowi.config;
+    const configManager = this.crowi.configManager;
+    const isOidcEnabled = configManager.getConfig('crowi', 'security:passport-oidc:isEnabled');
+
+    // when disabled
+    if (!isOidcEnabled) {
+      return;
+    }
+
+    debug('OidcStrategy: setting up..');
+
+    // setup client
+    // extend oidc request timeouts
+    OIDCIssuer.defaultHttpOptions = { timeout: 5000 };
+    const issuerHost = configManager.getConfig('crowi', 'security:passport-oidc:issuerHost') || process.env.OAUTH_OIDC_ISSUER_HOST;
+    const clientId = configManager.getConfig('crowi', 'security:passport-oidc:clientId') || process.env.OAUTH_OIDC_CLIENT_ID;
+    const clientSecret = configManager.getConfig('crowi', 'security:passport-oidc:clientSecret') || process.env.OAUTH_OIDC_CLIENT_SECRET;
+    const redirectUri = (configManager.getConfig('crowi', 'app:siteUrl') != null)
+      ? urljoin(this.crowi.configManager.getSiteUrl(), '/passport/oidc/callback')
+      : config.crowi['security:passport-oidc:callbackUrl'] || process.env.OAUTH_OIDC_CALLBACK_URI; // DEPRECATED: backward compatible with v3.2.3 and below
+    const oidcIssuer = await OIDCIssuer.discover(issuerHost);
+    debug('Discovered issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
+
+    const client = new oidcIssuer.Client({
+      client_id: clientId,
+      client_secret: clientSecret,
+      redirect_uris: [redirectUri],
+      response_types: ['code'],
+    });
+
+    passport.use('oidc', new OidcStrategy({
+      client,
+      params: { scope: 'openid email profile' },
+    },
+    ((tokenset, userinfo, done) => {
+      if (userinfo) {
+        return done(null, userinfo);
+      }
+
+      return done(null, false);
+
+    })));
+
+    this.isOidcStrategySetup = true;
+    debug('OidcStrategy: setup is done');
+  }
+
+  /**
+   * reset OidcStrategy
+   *
+   * @memberof PassportService
+   */
+  resetOidcStrategy() {
+    debug('OidcStrategy: reset');
+    passport.unuse('oidc');
+    this.isOidcStrategySetup = false;
+  }
+
   setupSamlStrategy() {
   setupSamlStrategy() {
     // check whether the strategy has already been set up
     // check whether the strategy has already been set up
     if (this.isSamlStrategySetup) {
     if (this.isSamlStrategySetup) {

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

@@ -150,6 +150,10 @@ exports.swigFilters = function(crowi, app, swig) {
       return pathUtils.removeTrailingSlash(string);
       return pathUtils.removeTrailingSlash(string);
     });
     });
 
 
+    swig.setFilter('addTrailingSlash', (string) => {
+      return pathUtils.addTrailingSlash(string);
+    });
+
     swig.setFilter('presentation', (string) => {
     swig.setFilter('presentation', (string) => {
       // 手抜き
       // 手抜き
       return string
       return string

+ 5 - 0
src/server/util/swigFunctions.js

@@ -173,6 +173,11 @@ module.exports = function(crowi, app, req, locals) {
     return locals.isEnabledPassport() && config.crowi['security:passport-twitter:isEnabled'];
     return locals.isEnabledPassport() && config.crowi['security:passport-twitter:isEnabled'];
   };
   };
 
 
+  locals.passportOidcLoginEnabled = function() {
+    const config = crowi.getConfig();
+    return locals.isEnabledPassport() && config.crowi['security:passport-oidc:isEnabled'];
+  };
+
   locals.searchConfigured = function() {
   locals.searchConfigured = function() {
     if (crowi.getSearcher()) {
     if (crowi.getSearcher()) {
       return true;
       return true;

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

@@ -5,7 +5,7 @@
 {% block content_header %}
 {% block content_header %}
 <div class="header-wrap">
 <div class="header-wrap">
   <header id="page-header">
   <header id="page-header">
-    <h1 id="admin-title" class="title">{{ t('User management') }}/{{ t('External Account management') }}</h1>
+    <h1 id="admin-title" class="title">{{ t('User_management') }}/{{ t('External Account management') }}</h1>
   </header>
   </header>
 </div>
 </div>
 {% endblock %}
 {% endblock %}
@@ -67,7 +67,7 @@
                 </small>
                 </small>
               </a>
               </a>
             </th>
             </th>
-            <th width="100px">{{ t('user_management.Date created') }}</th>
+            <th width="100px">{{ t('Created') }}</th>
             <th width="70px"></th>
             <th width="70px"></th>
           </tr>
           </tr>
         </thead>
         </thead>
@@ -100,7 +100,7 @@
                   <i class="icon-settings"></i> <span class="caret"></span>
                   <i class="icon-settings"></i> <span class="caret"></span>
                 </button>
                 </button>
                 <ul class="dropdown-menu" role="menu">
                 <ul class="dropdown-menu" role="menu">
-                  <li class="dropdown-header">{{ t('user_management.Edit menu') }}</li>
+                  <li class="dropdown-header">{{ t('user_management.Edit_menu') }}</li>
                   <form id="form_remove_{{ loop.index }}" action="/admin/users/external-accounts/{{ account._id.toString() }}/remove" method="post">
                   <form id="form_remove_{{ loop.index }}" action="/admin/users/external-accounts/{{ account._id.toString() }}/remove" method="post">
                     <input type="hidden" name="_csrf" value="{{ csrf() }}">
                     <input type="hidden" name="_csrf" value="{{ csrf() }}">
                   </form>
                   </form>

+ 8 - 1
src/server/views/admin/security.html

@@ -304,6 +304,9 @@
             <li>
             <li>
               <a href="#passport-twitter" data-toggle="tab" role="tab"><i class="fa fa-twitter"></i> Twitter</a>
               <a href="#passport-twitter" data-toggle="tab" role="tab"><i class="fa fa-twitter"></i> Twitter</a>
             </li>
             </li>
+            <li>
+              <a href="#passport-oidc" data-toggle="tab" role="tab"><i class="fa fa-openid"></i> OIDC</a>
+            </li>
             <li class="tbd">
             <li class="tbd">
               <a href="#passport-facebook" data-toggle="tab" role="tab"><i class="fa fa-facebook"></i> (TBD) Facebook</a>
               <a href="#passport-facebook" data-toggle="tab" role="tab"><i class="fa fa-facebook"></i> (TBD) Facebook</a>
             </li>
             </li>
@@ -330,6 +333,10 @@
               {% include './widget/passport/twitter.html' %}
               {% include './widget/passport/twitter.html' %}
             </div>
             </div>
 
 
+            <div id="passport-oidc" class="tab-pane" role="tabpanel">
+              {% include './widget/passport/oidc.html' %}
+            </div>
+
             <div id="passport-github" class="tab-pane" role="tabpanel">
             <div id="passport-github" class="tab-pane" role="tabpanel">
               {% include './widget/passport/github.html' %}
               {% include './widget/passport/github.html' %}
             </div>
             </div>
@@ -342,7 +349,7 @@
   </div>
   </div>
 
 
   <script>
   <script>
-    $('#generalSetting, #samlSetting, #googleSetting, #mechanismSetting, #githubSetting, #twitterSetting').each(function() {
+    $('#generalSetting, #samlSetting, #googleSetting, #mechanismSetting, #githubSetting, #twitterSetting, #oidcSetting').each(function() {
       $(this).submit(function()
       $(this).submit(function()
       {
       {
         function showMessage(formId, msg, status) {
         function showMessage(formId, msg, status) {

+ 25 - 25
src/server/views/admin/users.html

@@ -1,11 +1,11 @@
 {% extends '../layout/admin.html' %}
 {% extends '../layout/admin.html' %}
 
 
-{% block html_title %}{{ customTitle(t('user_management.User management')) }}{% endblock %}
+{% block html_title %}{{ customTitle(t('User_Management')) }}{% endblock %}
 
 
 {% block content_header %}
 {% block content_header %}
 <div class="header-wrap">
 <div class="header-wrap">
   <header id="page-header">
   <header id="page-header">
-    <h1 id="admin-title" class="title">{{ t('user_management.User management') }}</h1>
+    <h1 id="admin-title" class="title">{{ t('User_Management') }}</h1>
   </header>
   </header>
 </div>
 </div>
 {% endblock %}
 {% endblock %}
@@ -60,7 +60,7 @@
       <label>{{ t('user_management.cannot_invite_maximum_users') }}</label>
       <label>{{ t('user_management.cannot_invite_maximum_users') }}</label>
       {% endif %}
       {% endif %}
       {% if userUpperLimit !== 0 %}
       {% if userUpperLimit !== 0 %}
-      <label>{{ t('user_management.current users') }}{{ activeUsers }}</label>
+      <label>{{ t('user_management.current_users') }}{{ activeUsers }}</label>
       {% endif %}
       {% endif %}
 
 
       {% set createdUser = req.flash('createdUser') %}
       {% set createdUser = req.flash('createdUser') %}
@@ -71,13 +71,13 @@
 
 
             <div class="modal-header">
             <div class="modal-header">
               <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
               <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-              <div class="modal-title">ユーザーを招待しました</div>
+              <div class="modal-title">{{ t('user_management.invited') }}</div>
             </div>
             </div>
 
 
             <div class="modal-body">
             <div class="modal-body">
               <p>
               <p>
-                作成したユーザーは仮パスワードが設定されています。<br>
-                仮パスワードはこの画面を閉じると二度と表示できませんのでご注意ください。<span class="text-danger">招待メールを送っていない場合、この画面で必ず仮パスワードをコピーし、招待者へ連絡してください。</span>
+                {{ t('user_management.temporary_password') }}<br>
+                {{ t('user_management.password_never_seen') }}<span class="text-danger">{{ t('user_management.send_temporary_password') }}</span>
               </p>
               </p>
 
 
               <pre>{% for cUser in createdUser %}{% if cUser.user %}{{ cUser.email }} {{ cUser.password }}<br>{% else %}{{ cUser.email }} 作成失敗<br>{% endif %}{% endfor %}</pre>
               <pre>{% for cUser in createdUser %}{% if cUser.user %}{{ cUser.email }} {{ cUser.password }}<br>{% else %}{{ cUser.email }} 作成失敗<br>{% endif %}{% endfor %}</pre>
@@ -94,23 +94,23 @@
           <div class="modal-content">
           <div class="modal-content">
             <div class="modal-header">
             <div class="modal-header">
               <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
               <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-              <div class="modal-title">パスワードを新規発行しますか?</div>
+              <div class="modal-title">{{ t('user_management.reset_password')}}</div>
             </div>
             </div>
 
 
             <div class="modal-body">
             <div class="modal-body">
               <p>
               <p>
-              新規発行したパスワードはこの画面を閉じると二度と表示できませんのでご注意ください。<br>
-              <span class="text-danger">新規発行したパスワードを、対象ユーザーへ連絡してください。</span>
+                {{ t('user_management.password_never_seen') }}<br>
+              <span class="text-danger">{{ t('user_management.send_new_password') }}</span>
               </p>
               </p>
               <p>
               <p>
-              Reset user: <code id="admin-password-reset-user"></code>
+              {{ t('user_management.target_user') }}: <code id="admin-password-reset-user"></code>
               </p>
               </p>
 
 
               <form method="post" id="admin-users-reset-password">
               <form method="post" id="admin-users-reset-password">
                 <input type="hidden" name="user_id" value="">
                 <input type="hidden" name="user_id" value="">
                 <input type="hidden" name="_csrf" value="{{ csrf() }}">
                 <input type="hidden" name="_csrf" value="{{ csrf() }}">
                 <button type="submit" value="" class="btn btn-primary">
                 <button type="submit" value="" class="btn btn-primary">
-                  実行
+                  {{ t('user_management.reset_password')}}
                 </button>
                 </button>
               </form>
               </form>
 
 
@@ -125,7 +125,7 @@
 
 
             <div class="modal-header">
             <div class="modal-header">
               <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
               <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-              <div class="modal-title">Password reset!</div>
+              <div class="modal-title">{{ t('user_management.reset_password') }}</div>
             </div>
             </div>
 
 
             <div class="modal-body">
             <div class="modal-body">
@@ -144,18 +144,18 @@
         </div><!-- /.modal-dialog -->
         </div><!-- /.modal-dialog -->
       </div>
       </div>
 
 
-      <h2>{{ t("User List") }}</h2>
+      <h2>{{ t("User_Management") }}</h2>
 
 
       <table class="table table-default table-bordered table-user-list">
       <table class="table table-default table-bordered table-user-list">
         <thead>
         <thead>
           <tr>
           <tr>
             <th width="100px">#</th>
             <th width="100px">#</th>
-            <th>{{ t('user_management.Status') }}</th>
-            <th>{{ t('Name') }}</th>
+            <th>{{ t('status') }}</th>
+            <th><code>{{ t('User') }}</code></th>
             <th>{{ t('Name') }}</th>
             <th>{{ t('Name') }}</th>
             <th>{{ t('Email') }}</th>
             <th>{{ t('Email') }}</th>
             <th width="100px">{{ t('Created') }}</th>
             <th width="100px">{{ t('Created') }}</th>
-            <th width="150px">{{ t('Last Login') }}</th>
+            <th width="150px">{{ t('Last_Login') }}</th>
             <th width="70px"></th>
             <th width="70px"></th>
           </tr>
           </tr>
         </thead>
         </thead>
@@ -167,7 +167,7 @@
               <img src="{{ sUser|picture }}" class="picture img-circle" />
               <img src="{{ sUser|picture }}" class="picture img-circle" />
               {% if sUser.admin %}
               {% if sUser.admin %}
               <span class="label label-inverse label-admin">
               <span class="label label-inverse label-admin">
-                Admin
+              {{ t('administrator') }}
               </span>
               </span>
               {% endif %}
               {% endif %}
             </td>
             </td>
@@ -193,7 +193,7 @@
                   <i class="icon-settings"></i> <span class="caret"></span>
                   <i class="icon-settings"></i> <span class="caret"></span>
                 </button>
                 </button>
                 <ul class="dropdown-menu" role="menu">
                 <ul class="dropdown-menu" role="menu">
-                  <li class="dropdown-header">{{ t('user_management.Edit menu') }}</li>
+                  <li class="dropdown-header">{{ t('user_management.edit_menu') }}</li>
                   <li>
                   <li>
                     <a href="#"
                     <a href="#"
                         data-user-id="{{ sUserId }}"
                         data-user-id="{{ sUserId }}"
@@ -201,11 +201,11 @@
                         data-target="#admin-password-reset-modal"
                         data-target="#admin-password-reset-modal"
                         data-toggle="modal">
                         data-toggle="modal">
                       <i class="icon-fw icon-key"></i>
                       <i class="icon-fw icon-key"></i>
-                      {{ t('user_management.Reissue password') }}
+                      {{ t('user_management.reset_password') }}
                     </a>
                     </a>
                   </li>
                   </li>
                   <li class="divider"></li>
                   <li class="divider"></li>
-                  <li class="dropdown-header">{{ t('user_management.Status') }}</li>
+                  <li class="dropdown-header">{{ t('status') }}</li>
 
 
                   {% if sUser.status == 1 %}
                   {% if sUser.status == 1 %}
                   <form id="form_activate_{{ sUserId }}" action="/admin/user/{{ sUserId }}/activate" method="post">
                   <form id="form_activate_{{ sUserId }}" action="/admin/user/{{ sUserId }}/activate" method="post">
@@ -226,12 +226,12 @@
                     {% if sUser.username != user.username %}
                     {% if sUser.username != user.username %}
                     <a href="javascript:form_suspend_{{ sUserId }}.submit()">
                     <a href="javascript:form_suspend_{{ sUserId }}.submit()">
                       <i class="icon-fw icon-ban"></i>
                       <i class="icon-fw icon-ban"></i>
-                      {{ t('user_management.Deactivate account') }}
+                      {{ t('user_management.deactivate_account') }}
                     </a>
                     </a>
                     {% else %}
                     {% else %}
                     <a disabled>
                     <a disabled>
                       <i class="icon-fw icon-ban"></i>
                       <i class="icon-fw icon-ban"></i>
-                      {{ t('user_management.Deactivate account') }}
+                      {{ t('user_management.deactivate_account') }}
                     </a>
                     </a>
                     <p class="alert alert-danger m-l-10 m-r-10 p-10">{{ t("user_management.your_own") }}</p>
                     <p class="alert alert-danger m-l-10 m-r-10 p-10">{{ t("user_management.your_own") }}</p>
                     {% endif %}
                     {% endif %}
@@ -256,7 +256,7 @@
                       <i class="icon-fw icon-fire text-danger"></i> {{ t('Delete') }}
                       <i class="icon-fw icon-fire text-danger"></i> {{ t('Delete') }}
                     </a>
                     </a>
                   </li>
                   </li>
-                  {% endif  %}
+                  {% endif %}
 
 
                   {% if sUser.status == 1 || sUser.status == 5 %}
                   {% if sUser.status == 1 || sUser.status == 5 %}
                   <form id="form_removeCompletely_{{ sUserId }}" action="/admin/user/{{ sUser._id.toString() }}/removeCompletely" method="post">
                   <form id="form_removeCompletely_{{ sUserId }}" action="/admin/user/{{ sUser._id.toString() }}/removeCompletely" method="post">
@@ -268,11 +268,11 @@
                       <i class="icon-fw icon-fire text-danger"></i> {{ t('Delete') }}
                       <i class="icon-fw icon-fire text-danger"></i> {{ t('Delete') }}
                     </a>
                     </a>
                   </li>
                   </li>
-                  {% endif  %}
+                  {% endif %}
 
 
                   {% if sUser.status == 2 %} {# activated な人だけこのメニューを表示 #}
                   {% if sUser.status == 2 %} {# activated な人だけこのメニューを表示 #}
                   <li class="divider"></li>
                   <li class="divider"></li>
-                  <li class="dropdown-header">{{ t('user_management.Administrator menu') }}</li>
+                  <li class="dropdown-header">{{ t('user_management.administrator_menu') }}</li>
 
 
                   {% if sUser.admin %}
                   {% if sUser.admin %}
                   <form id="form_removeFromAdmin_{{ sUserId }}" action="/admin/user/{{ sUser._id.toString() }}/removeFromAdmin" method="post">
                   <form id="form_removeFromAdmin_{{ sUserId }}" action="/admin/user/{{ sUser._id.toString() }}/removeFromAdmin" method="post">

+ 1 - 1
src/server/views/admin/widget/menu.html

@@ -9,7 +9,7 @@
   <li class="{% if current == 'customize'%}active{% endif %}"><a href="/admin/customize"><i class="icon-fw icon-wrench"></i> {{ t('Customize') }}</a></li>
   <li class="{% if current == 'customize'%}active{% endif %}"><a href="/admin/customize"><i class="icon-fw icon-wrench"></i> {{ t('Customize') }}</a></li>
   <li class="{% if current == 'importer'%}active{% endif %}"><a href="/admin/importer"><i class="icon-fw icon-cloud-download"></i> {{ t('Import Data') }}</a></li>
   <li class="{% if current == 'importer'%}active{% endif %}"><a href="/admin/importer"><i class="icon-fw icon-cloud-download"></i> {{ t('Import Data') }}</a></li>
   <li class="{% if current == 'notification'%}active{% endif %}"><a href="/admin/notification"><i class="icon-fw icon-bell"></i> {{ t('Notification Settings') }}</a></li>
   <li class="{% if current == 'notification'%}active{% endif %}"><a href="/admin/notification"><i class="icon-fw icon-bell"></i> {{ t('Notification Settings') }}</a></li>
-  <li class="{% if current == 'user' || current == 'external-account' %}active{% endif %}"><a href="/admin/users"><i class="icon-fw icon-user"></i> {{ t('User Management') }}</a></li>
+  <li class="{% if current == 'user' || current == 'external-account' %}active{% endif %}"><a href="/admin/users"><i class="icon-fw icon-user"></i> {{ t('User_Management') }}</a></li>
   <li class="{% if current == 'user-group'%}active{% endif %}"><a href="/admin/user-groups"><i class="icon-fw icon-people"></i> {{ t('UserGroup Management') }}</a></li>
   <li class="{% if current == 'user-group'%}active{% endif %}"><a href="/admin/user-groups"><i class="icon-fw icon-people"></i> {{ t('UserGroup Management') }}</a></li>
   <li class="{% if current == 'search'%}active{% endif %}"><a href="/admin/search"><i class="icon-fw icon-magnifier"></i> {{ t('Full Text Search Management') }}</a></li>
   <li class="{% if current == 'search'%}active{% endif %}"><a href="/admin/search"><i class="icon-fw icon-magnifier"></i> {{ t('Full Text Search Management') }}</a></li>
 </ul>
 </ul>

+ 205 - 0
src/server/views/admin/widget/passport/oidc.html

@@ -0,0 +1,205 @@
+<form action="/_api/admin/security/passport-oidc" method="post" class="form-horizontal passportStrategy" id="oidcSetting" role="form"
+    {% if isRestartingServerNeeded %}style="opacity: 0.4;"{% endif %}>
+  <legend class="alert-anchor">{{ t("security_setting.OAuth.OIDC.name") }} {{ t("security_setting.configuration") }}</legend>
+
+  {% set nameForIsOIDCEnabled = "settingForm[security:passport-oidc:isEnabled]" %}
+  {% set isOidcEnabled = settingForm['security:passport-oidc:isEnabled'] %}
+  {% set siteUrl = getConfig('crowi', 'app:siteUrl') || '[INVALID]' %}
+  {% set callbackUrl = siteUrl + '/passport/oidc/callback' %}
+
+  <div class="form-group">
+    <label for="{{nameForIsOIDCEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.OIDC.name") }}</label>
+    <div class="col-xs-6">
+      <div class="btn-group btn-toggle" data-toggle="buttons">
+        <label class="btn btn-default btn-rounded btn-outline {% if isOidcEnabled %}active{% endif %}" data-active-class="primary">
+          <input name="{{nameForIsOIDCEnabled}}" value="true" type="radio"
+              {% if true === isOidcEnabled %}checked{% endif %}> ON
+        </label>
+        <label class="btn btn-default btn-rounded btn-outline {% if !isOidcEnabled %}active{% endif %}" data-active-class="default">
+          <input name="{{nameForIsOIDCEnabled}}" value="false" type="radio"
+              {% if !isOidcEnabled %}checked{% endif %}> OFF
+        </label>
+      </div>
+    </div>
+  </div>
+  <fieldset id="passport-oidc-hide-when-disabled" {%if !isOidcEnabled %}style="display: none;"{% endif %}>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-oidc:providerName]" class="col-xs-3 control-label">{{ t("security_setting.providerName") }}</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-oidc:providerName]" value="{{ settingForm['security:passport-oidc:providerName'] || '' }}">
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-oidc:issuerHost]" class="col-xs-3 control-label">{{ t("security_setting.issuerHost") }}</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-oidc:issuerHost]" value="{{ settingForm['security:passport-oidc:issuerHost'] || '' }}">
+        <p class="help-block">
+          <small>
+                {{ t("security_setting.Use env var if empty", "OAUTH_OIDC_ISSUER_HOST") }}
+          </small>
+        </p>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-oidc:clientId]" class="col-xs-3 control-label">{{ t("security_setting.clientID") }}</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-oidc:clientId]" value="{{ settingForm['security:passport-oidc:clientId'] || '' }}">
+        <p class="help-block">
+          <small>
+             {{ t("security_setting.Use env var if empty", "OAUTH_OIDC_CLIENT_ID") }}
+          </small>
+        </p>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-oidc:clientSecret]" class="col-xs-3 control-label">{{ t("security_setting.client_secret") }}</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-oidc:clientSecret]" value="{{ settingForm['security:passport-oidc:clientSecret'] || '' }}">
+        <p class="help-block">
+          <small>
+             {{ t("security_setting.Use env var if empty", "OAUTH_OIDC_CLIENT_SECRET") }}
+          </small>
+        </p>
+      </div>
+    </div>
+
+    <h4>Attribute Mapping ({{ t("security_setting.optional") }})</h4>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-oidc:attrMapId]" class="col-xs-3 control-label">Identifier</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-oidc:attrMapId]" value="{{ settingForm['security:passport-oidc:attrMapId'] || '' }}">
+        <p class="help-block">
+          <small>
+            {{ t("security_setting.OAuth.OIDC.id_detail") }}
+          </small>
+        </p>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-oidc:attrMapUserName]" class="col-xs-3 control-label">Username</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-oidc:attrMapUserName]" value="{{ settingForm['security:passport-oidc:attrMapUserName'] || '' }}">
+        <p class="help-block">
+          <small>
+            {{ t("security_setting.OAuth.OIDC.username_detail") }}
+          </small>
+        </p>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-oidc:attrMapName]" class="col-xs-3 control-label">Name</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-oidc:attrMapName]" value="{{ settingForm['security:passport-oidc:attrMapName'] || '' }}">
+        <p class="help-block">
+          <small>
+            {{ t("security_setting.OAuth.OIDC.name_detail") }}
+          </small>
+        </p>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-oidc:attrMapMail]" class="col-xs-3 control-label">Mail</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-oidc:attrMapMail]" value="{{ settingForm['security:passport-oidc:attrMapMail'] || '' }}">
+        <p class="help-block">
+          <small>
+            {{ t("security_setting.OAuth.OIDC.mapping_detail", t("Email")) }}
+          </small>
+        </p>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
+      <div class="col-xs-6">
+          <input class="form-control" type="text" value="{{ callbackUrl }}" readonly>
+        <p class="help-block small">{{ t("security_setting.desc_of_callback_URL", 'OAuth') }}</p>
+        {% if !getConfig('crowi', 'app:siteUrl') %}
+        <div class="alert alert-danger">
+          <i class="icon-exclamation"></i> {{ t("security_setting.alert_siteUrl_is_not_set", '<a href="/admin/app">' + t('App settings') + '<i class="icon-login"></i></a>') }}
+        </div>
+        {% endif %}
+      </div>
+    </div>
+
+    <div class="form-group">
+      <div class="col-xs-6 col-xs-offset-3">
+        <div class="checkbox checkbox-info">
+          <input type="checkbox" id="bindByUserName-oidc" name="settingForm[security:passport-oidc:isSameUsernameTreatedAsIdenticalUser]" value="1"
+              {% if settingForm['security:passport-oidc:isSameUsernameTreatedAsIdenticalUser'] %}checked{% endif %} />
+          <label for="bindByUserName-oidc">
+            {{ t("security_setting.Treat username matching as identical", "username") }}
+          </label>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.Treat username matching as identical_warn", "username") }}
+            </small>
+          </p>
+        </div>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <div class="col-xs-6 col-xs-offset-3">
+        <div class="checkbox checkbox-info">
+          <input type="checkbox" id="bindByEmail-oidc" name="settingForm[security:passport-oidc:isSameEmailTreatedAsIdenticalUser]" value="1"
+              {% if settingForm['security:passport-oidc:isSameEmailTreatedAsIdenticalUser'] %}checked{% endif %} />
+          <label for="bindByEmail-oidc">
+            {{ t("security_setting.Treat email matching as identical", "email") }}
+          </label>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.Treat email matching as identical_warn", "email") }}
+            </small>
+          </p>
+        </div>
+      </div>
+    </div>
+
+  </fieldset>
+
+  <div class="form-group" id="btn-update">
+    <div class="col-xs-offset-3 col-xs-6">
+      <input type="hidden" name="_csrf" value="{{ csrf() }}">
+      <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
+    </div>
+  </div>
+
+</form>
+
+{# Help Section #}
+<hr>
+
+<div style="min-height: 300px;">
+  <h4>
+    <i class="icon-question" aria-hidden="true"></i>
+    <a href="#collapseHelpForOidcOauth" data-toggle="collapse">{{ t("security_setting.OAuth.how_to.oidc") }}</a>
+  </h4>
+  <ol id="collapseHelpForOidcOauth" class="collapse">
+    <li>{{ t("security_setting.OAuth.OIDC.register_1") }}</li>
+    <li>{{ t("security_setting.OAuth.OIDC.register_2", callbackUrl) }}</li>
+    <li>{{ t("security_setting.OAuth.OIDC.register_3") }}</li>
+  </ol>
+</div>
+
+<script>
+  $('input[name="settingForm[security:passport-oidc:isEnabled]"]').change(function() {
+      const isEnabled = ($(this).val() === "true");
+
+      if (isEnabled) {
+        $('#passport-oidc-hide-when-disabled').show(400);
+      }
+      else {
+        $('#passport-oidc-hide-when-disabled').hide(400);
+      }
+    });
+</script>
+

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

@@ -239,6 +239,9 @@
 
 
 </div><!-- /#wrapper -->
 </div><!-- /#wrapper -->
 
 
+<!-- /#staff-credit -->
+<div id="staff-credit"></div>
+
 {% include '../modal/shortcuts.html' %}
 {% include '../modal/shortcuts.html' %}
 
 
 {% block body_end %}
 {% block body_end %}

+ 11 - 1
src/server/views/login.html

@@ -144,7 +144,7 @@
           </form>
           </form>
         </div>
         </div>
         {% endif %}
         {% endif %}
-        {% if passportGoogleLoginEnabled() || passportGitHubLoginEnabled() || passportFacebookLoginEnabled() || passportTwitterLoginEnabled() || passportSamlLoginEnabled() %}
+        {% if passportGoogleLoginEnabled() || passportGitHubLoginEnabled() || passportFacebookLoginEnabled() || passportTwitterLoginEnabled() || passportOidcLoginEnabled() || passportSamlLoginEnabled() %}
         <hr class="mb-1">
         <hr class="mb-1">
         <div class="collapse collapse-oauth collapse-anchor">
         <div class="collapse collapse-oauth collapse-anchor">
           <div class="spacer"></div>
           <div class="spacer"></div>
@@ -188,6 +188,16 @@
               <div class="small text-right">by Twitter Account</div>
               <div class="small text-right">by Twitter Account</div>
             </form>
             </form>
             {% endif %}
             {% endif %}
+            {% if passportOidcLoginEnabled() %}
+            <form role="form" action="/passport/oidc" class="d-inline-flex flex-column">
+              <input type="hidden" name="_csrf" value="{{ csrf() }}">
+              <button type="submit" class="fcbtn btn btn-1b btn-login-oauth d-inline-flex" id="oidc">
+                <span class="btn-label"><i class="fa fa-openid"></i></span>
+                <span class="btn-label-text">{{ t('Sign in') }}</span>
+              </button>
+              <div class="small text-right">{{ config.crowi['security:passport-oidc:providerName'] || "OpenID Connect" }}</div>
+            </form>
+            {% endif %}
             {% if passportSamlLoginEnabled() %}
             {% if passportSamlLoginEnabled() %}
             <form role="form" action="/passport/saml" class="d-inline-flex flex-column">
             <form role="form" action="/passport/saml" class="d-inline-flex flex-column">
               <input type="hidden" name="_csrf" value="{{ csrf() }}">
               <input type="hidden" name="_csrf" value="{{ csrf() }}">

+ 1 - 2
src/server/views/me/index.html

@@ -158,10 +158,9 @@
             <img src="{{ user|uploadedpicture }}" class="picture picture-lg img-circle" id="settingUserPicture"><br>
             <img src="{{ user|uploadedpicture }}" class="picture picture-lg img-circle" id="settingUserPicture"><br>
             </p>
             </p>
             <p>
             <p>
-            <form id="remove-attachment" action="/_api/attachments.remove" method="post" class="form-horizontal"
+            <form id="remove-attachment" action="/_api/attachments.removeProfileImage" method="post" class="form-horizontal"
                 style="{% if not user.imageAttachment %}display: none{% endif %}">
                 style="{% if not user.imageAttachment %}display: none{% endif %}">
               <input type="hidden" name="_csrf" value="{{ csrf() }}">
               <input type="hidden" name="_csrf" value="{{ csrf() }}">
-              <input type="hidden" name="attachment_id" value="{{ user.imageAttachment.id }}">
               <button type="submit" class="btn btn-danger">{{ t('Delete Image') }}</button>
               <button type="submit" class="btn btn-danger">{{ t('Delete Image') }}</button>
             </form>
             </form>
             </p>
             </p>

+ 2 - 1
src/server/views/modal/create_page.html

@@ -72,7 +72,8 @@
             // modify href
             // modify href
             const value = $(this).val();
             const value = $(this).val();
             const pageName = (value === 'children') ? '_template' : '__template';
             const pageName = (value === 'children') ? '_template' : '__template';
-            const link = '{{ page.path || path }}/' + pageName + '#edit-form';
+            const truePath = "{% if page.path || path %}{{ page.path || path | addTrailingSlash }}{% else %}{{ page.path || path }}{% endif %}"
+            const link = truePath + pageName + '#edit-form';
             $('#link-to-template').attr('href', link);
             $('#link-to-template').attr('href', link);
           });
           });
         </script>
         </script>

+ 2 - 0
src/server/views/modal/delete.html

@@ -22,12 +22,14 @@
 
 
           <hr>
           <hr>
 
 
+          {% if page.grant != 2 %}
           <div class="checkbox checkbox-warning">
           <div class="checkbox checkbox-warning">
             <input name="recursively" id="cbDeleteRecursively" value="1" type="checkbox" checked>
             <input name="recursively" id="cbDeleteRecursively" value="1" type="checkbox" checked>
             <label for="cbDeleteRecursively">{{ t('modal_delete.label.Delete recursively') }}</label>
             <label for="cbDeleteRecursively">{{ t('modal_delete.label.Delete recursively') }}</label>
             <p class="help-block"> {{ t('modal_delete.help.recursively', page.path) }}
             <p class="help-block"> {{ t('modal_delete.help.recursively', page.path) }}
             </p>
             </p>
           </div>
           </div>
+          {% endif %}
           {% if not page.isDeleted() %}
           {% if not page.isDeleted() %}
           <div class="checkbox checkbox-danger">
           <div class="checkbox checkbox-danger">
             <input name="completely" id="cbDeleteCompletely" value="1"  type="checkbox">
             <input name="completely" id="cbDeleteCompletely" value="1"  type="checkbox">

+ 3 - 1
src/server/views/widget/not_found_content.html

@@ -10,7 +10,9 @@
 <div id="content-main" class="content-main content-main-not-found page-list"
 <div id="content-main" class="content-main content-main-not-found page-list"
   data-path="{{ path | preventXss }}"
   data-path="{{ path | preventXss }}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
-  data-template-tags="{{ templateTags | '' }}"
+  {% if templateTags %}
+    data-template-tags="{{ templateTags }}"
+  {% endif %}
   >
   >
 
 
   {% include 'not_found_tabs.html' %}
   {% include 'not_found_tabs.html' %}

+ 2 - 1
src/server/views/widget/page_alerts.html

@@ -1,6 +1,7 @@
 <div class="row row-alerts">
 <div class="row row-alerts">
   <div class="col-xs-12">
   <div class="col-xs-12">
-    {% if page && page.grant != 1 %}
+    {% if page && page.grant && page.grant > 1 %}
+
       <p class="alert alert-inverse alert-grant">
       <p class="alert alert-inverse alert-grant">
       {% if page.grant == 2 %}
       {% if page.grant == 2 %}
         <i class="icon-fw icon-link"></i><strong>{{ consts.pageGrants[page.grant] }}</strong> ({{ t('Browsing of this page is restricted') }})
         <i class="icon-fw icon-link"></i><strong>{{ consts.pageGrants[page.grant] }}</strong> ({{ t('Browsing of this page is restricted') }})

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


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