Преглед изворни кода

Merge branch 'master' into feat/bulk-export-pages-for-merge

# Conflicts:
#	src/client/js/base.jsx
#	src/client/js/components/Page/PageShareManagement.jsx
#	src/server/views/widget/page_content.html
yusuketk пре 5 година
родитељ
комит
5654b6bb5b
100 измењених фајлова са 1578 додато и 602 уклоњено
  1. 7 0
      CHANGES.md
  2. 0 4
      config/webpack.common.js
  3. BIN
      public/images/agile-admin/tooltip/Euclid.png
  4. 0 8
      public/images/agile-admin/tooltip/shape1.svg
  5. 0 18
      public/images/agile-admin/tooltip/shape2.svg
  6. 0 5
      public/images/agile-admin/tooltip/shape3.svg
  7. 0 8
      public/images/agile-admin/tooltip/tooltip1.svg
  8. 0 6
      public/images/agile-admin/tooltip/tooltip2.svg
  9. 0 6
      public/images/agile-admin/tooltip/tooltip3.svg
  10. 4 1
      resource/locales/en_US/admin/admin.json
  11. 25 7
      resource/locales/en_US/translation.json
  12. 3 0
      resource/locales/ja_JP/admin/admin.json
  13. 26 7
      resource/locales/ja_JP/translation.json
  14. 4 1
      resource/locales/zh_CN/admin/admin.json
  15. 29 8
      resource/locales/zh_CN/translation.json
  16. 1 1
      src/client/js/app.jsx
  17. 3 4
      src/client/js/base.jsx
  18. 29 67
      src/client/js/components/Admin/App/AppSettingsPage.jsx
  19. 62 0
      src/client/js/components/Admin/App/AppSettingsPageContents.jsx
  20. 65 2
      src/client/js/components/Admin/App/MailSetting.jsx
  21. 4 5
      src/client/js/components/Admin/Security/DeleteAllShareLinksModal.jsx
  22. 10 41
      src/client/js/components/Admin/Security/ShareLinkSetting.jsx
  23. 1 1
      src/client/js/components/BookmarkButton.jsx
  24. 1 1
      src/client/js/components/LikeButton.jsx
  25. 9 7
      src/client/js/components/OutsideShareLinkModal.jsx
  26. 3 0
      src/client/js/components/Page.jsx
  27. 22 13
      src/client/js/components/Page/CopyDropdown.jsx
  28. 38 11
      src/client/js/components/Page/PageManagement.jsx
  29. 5 2
      src/client/js/components/Page/PageShareManagement.jsx
  30. 18 5
      src/client/js/components/Page/ShareLinkAlert.jsx
  31. 0 1
      src/client/js/components/PageCreateModal.jsx
  32. 0 1
      src/client/js/components/PageDuplicateModal.jsx
  33. 26 1
      src/client/js/components/PageEditor/CodeMirrorEditor.jsx
  34. 353 0
      src/client/js/components/PageEditor/LinkEditModal.jsx
  35. 48 0
      src/client/js/components/PageEditor/MarkdownLinkUtil.js
  36. 7 2
      src/client/js/components/PageHistory.jsx
  37. 0 2
      src/client/js/components/PagePathAutoComplete.jsx
  38. 95 81
      src/client/js/components/ShareLinkForm.jsx
  39. 16 6
      src/client/js/components/ShareLinkList.jsx
  40. 3 1
      src/client/js/components/Sidebar.jsx
  41. 10 1
      src/client/js/components/Sidebar/SidebarContents.jsx
  42. 147 0
      src/client/js/models/Linker.js
  43. 16 1
      src/client/js/services/AdminAppContainer.js
  44. 1 0
      src/client/js/services/PageContainer.js
  45. 3 3
      src/client/styles/scss/_admin.scss
  46. 1 1
      src/client/styles/scss/_comment.scss
  47. 2 2
      src/client/styles/scss/_comment_kibela.scss
  48. 4 4
      src/client/styles/scss/_editor-attachment.scss
  49. 1 1
      src/client/styles/scss/_editor-overlay.scss
  50. 4 4
      src/client/styles/scss/_hljs.scss
  51. 5 5
      src/client/styles/scss/_layout.scss
  52. 8 0
      src/client/styles/scss/_linkedit-preview.scss
  53. 7 7
      src/client/styles/scss/_login.scss
  54. 1 1
      src/client/styles/scss/_navbar_kibela.scss
  55. 28 14
      src/client/styles/scss/_on-edit.scss
  56. 1 1
      src/client/styles/scss/_override-bootstrap-variables.scss
  57. 4 4
      src/client/styles/scss/_page.scss
  58. 3 3
      src/client/styles/scss/_page_list.scss
  59. 5 5
      src/client/styles/scss/_search.scss
  60. 3 3
      src/client/styles/scss/_shortcuts.scss
  61. 1 0
      src/client/styles/scss/_sidebar.scss
  62. 1 1
      src/client/styles/scss/_subnav.scss
  63. 2 2
      src/client/styles/scss/_user.scss
  64. 1 0
      src/client/styles/scss/_wiki.scss
  65. 16 9
      src/client/styles/scss/atoms/_buttons.scss
  66. 1 0
      src/client/styles/scss/style-app.scss
  67. 5 5
      src/client/styles/scss/style-presentation.scss
  68. 5 5
      src/client/styles/scss/theme/_apply-colors-dark.scss
  69. 2 2
      src/client/styles/scss/theme/_apply-colors-kibela.scss
  70. 7 7
      src/client/styles/scss/theme/antarctic.scss
  71. 9 9
      src/client/styles/scss/theme/christmas.scss
  72. 5 5
      src/client/styles/scss/theme/default.scss
  73. 1 1
      src/client/styles/scss/theme/future.scss
  74. 3 3
      src/client/styles/scss/theme/halloween.scss
  75. 5 5
      src/client/styles/scss/theme/island.scss
  76. 1 1
      src/client/styles/scss/theme/kibela.scss
  77. 4 4
      src/client/styles/scss/theme/mono-blue.scss
  78. 4 4
      src/client/styles/scss/theme/nature.scss
  79. 3 3
      src/client/styles/scss/theme/spring.scss
  80. 4 4
      src/client/styles/scss/theme/wood.scss
  81. 5 5
      src/linter-checker/test.scss
  82. 7 1
      src/server/middlewares/access-token-parser.js
  83. 46 0
      src/server/middlewares/certify-shared-file.js
  84. 31 0
      src/server/middlewares/certify-shared-page.js
  85. 6 0
      src/server/middlewares/login-required.js
  86. 12 7
      src/server/models/page.js
  87. 1 1
      src/server/models/revision.js
  88. 65 16
      src/server/routes/apiv3/app-settings.js
  89. 1 1
      src/server/routes/apiv3/share-links.js
  90. 5 4
      src/server/routes/index.js
  91. 15 0
      src/server/routes/page.js
  92. 18 93
      src/server/routes/revision.js
  93. 1 1
      src/server/views/admin/customize.html
  94. 8 4
      src/server/views/layout-growi/shared_page.html
  95. 2 2
      src/server/views/layout-kibela/shared_page.html
  96. 2 0
      src/server/views/widget/page_content.html
  97. 2 2
      src/server/views/widget/page_tabs.html
  98. 1 1
      src/server/views/widget/page_tabs_kibela.html
  99. 83 0
      src/test/middlewares/access-token-parser.test.js
  100. 16 0
      src/test/middlewares/login-required.test.js

+ 7 - 0
CHANGES.md

@@ -2,7 +2,14 @@
 
 ## v4.1.1-RC
 
+* Feature: External share link
+* Feature: Create/edit linker with GUI
+* Improvement: Optimize some features that operate revision data
+    * Page history
+    * Renaming pages
+    * Deleting pages
 * Fix: "Append params" switch of CopyDropdown does not work when multiple CopyDropdown instance exists
+* Fix: Access token parser
 
 ## v4.1.0
 

+ 0 - 4
config/webpack.common.js

@@ -96,10 +96,6 @@ module.exports = (options) => {
             basenameAsNamespace: true,
           },
         },
-        { // see https://github.com/abpetkov/switchery/issues/120
-          test: /switchery\.js$/,
-          loader: 'imports-loader?module=>false,exports=>false,define=>false,this=>window',
-        },
         /*
          * File loader for supporting images, for example, in CSS files.
          */

BIN
public/images/agile-admin/tooltip/Euclid.png


+ 0 - 8
public/images/agile-admin/tooltip/shape1.svg

@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200" viewBox="0 0 200 200" preserveAspectRatio="none">
-<path fill="#00AEEF" d="M174.209,28.162C154.645,8.88,124.289,2.08,100.06,2.08c-0.074,0-0.06-0.079-0.06-0.079
-	s0.015,0.079-0.06,0.079c-24.229,0-54.584,6.8-74.149,26.082C5.417,48.242,3,75,3,100s2.418,51.758,22.792,71.838
-	c19.564,19.281,49.92,26.082,74.149,26.082c0.074,0,0.06,0.079,0.06,0.079s-0.015-0.079,0.06-0.079
-	c24.229,0,54.585-6.801,74.149-26.082C194.582,151.758,197,125,197,100S194.582,48.242,174.209,28.162z"/>
-</svg>

+ 0 - 18
public/images/agile-admin/tooltip/shape2.svg

@@ -1,18 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200px" height="150px" viewBox="0 0 200 150">
-<g>
-	<path id="path1" fill="#010101" d="M159.599,137.909c0.975,3.397,4.717,5.548,8.161,4.988c3.489-0.443,6.558-3.466,6.685-7.043
-		c0.217-3.19-1.805-6.34-5.113-7.118c-3.417-1.079-7.469,0.508-9.138,3.701c-0.91,1.636-1.166,3.624-0.612,5.414"/>
-	<path id="path2" fill="#010101" d="M130.646,125.253c1.368,4.656,6.393,7.288,10.806,6.718c4.763-0.451,9.26-4.276,9.71-9.394
-		c0.369-3.779-1.902-7.583-5.244-9.144c-5.404-2.732-12.557-0.222-14.908,5.448c-0.841,1.945-1.018,4.214-0.388,6.294"/>
-	<path id="path3" fill="#010101" d="M184.112,144.325c0.704,2.461,3.412,4.016,5.905,3.611c2.526-0.318,4.746-2.509,4.841-5.093
-		c0.153-2.315-1.483-4.54-3.703-5.155c-2.474-0.781-5.405,0.37-6.612,2.681c-0.657,1.181-0.845,2.619-0.442,3.917"/>
-	<path id="path4" fill="#010101" d="M53.149,10.686c12.101-3.695,24.478-1.625,33.84,4.571c3.187-5.687,8.381-10.144,14.943-12.148
-		c10.427-3.185,21.37,0.699,28.159,8.982c15.606-3.76,31.369,4.398,35.804,18.915c3.269,10.699-0.488,21.956-8.71,29.388
-		c0.395,0.934,0.762,1.882,1.064,2.873c4.73,15.485-3.992,31.889-19.473,36.617c-5.073,1.551-10.251,1.625-15.076,0.518
-		c-3.58,10.605-12.407,19.55-24.386,23.211c-15.015,4.586-30.547-0.521-39.226-11.624c-2.861,1.991-6.077,3.564-9.583,4.636
-		c-18.43,5.631-38.04-5.068-43.785-23.874l-0.083-0.272C1.564,75.375,9.696,57.543,25.083,50.302
-		C23.349,33.157,34.85,16.276,53.149,10.686L53.149,10.686z"/>
-</g>
-</svg>

+ 0 - 5
public/images/agile-admin/tooltip/shape3.svg

@@ -1,5 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200px" height="150px" viewBox="0 0 200 150" enable-background="new 0 0 200 150" xml:space="preserve">
-<polygon fill="#FFFFFF" stroke="#000000" points="29.857,3.324 171.111,3.324 196.75,37.671 184.334,107.653 104.355,136.679 100,146.676 96.292,136.355 16.312,107.653 3.25,37.671 "/>
-</svg>

+ 0 - 8
public/images/agile-admin/tooltip/tooltip1.svg

@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="30px" height="20px" viewBox="0 0 30 20">
-	<g>
-		<path fill="#fb9678" d="M7.065,7.067C13.462,10.339,15,19.137,15,19.137V0H0C0,0,1.865,4.407,7.065,7.067z"/>
-		<path fill="#fb9678" d="M15,0v19.137c0,0,1.537-8.797,7.936-12.07C28.135,4.407,30,0,30,0H15z"/>
-	</g>
-</svg>

+ 0 - 6
public/images/agile-admin/tooltip/tooltip2.svg

@@ -1,6 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80px" height="80px" viewBox="0 0 80 80">
-<path fill="#e35583" d="M80,0c0,0-5.631,14.445-25.715,27.213C29.946,42.688,12.79,33.997,3.752,30.417
-	c-3.956-1.567-4.265,1.021-2.966,3.814C16.45,67.934,80,79.614,80,79.614l0,0V0z"/>
-</svg>

+ 0 - 6
public/images/agile-admin/tooltip/tooltip3.svg

@@ -1,6 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="60px" height="120px" preserveAspectRatio="none" viewBox="0 0 60 120">
-<path fill="#ffffff" d="M55.451-0.043C55.451-0.043,66.059-41.066,55.451-0.043C51.069,16.9,0.332,119.498,0.332,119.498
-	S43.365,18.315,39.532-0.043c-4.099-19.616,0,0,0,0"/>
-</svg>

+ 4 - 1
resource/locales/en_US/admin/admin.json

@@ -26,7 +26,7 @@
     "enable_files_except_image": "Enabling this option will allow upload of any file type. Without this option, only image file upload is supported.",
     "attach_enable": "You can attach files other than image files if you enable this option.",
     "update": "Update",
-    "mail_settings": "Mail settings",
+    "mail_settings": "E-mail Settings",
     "smtp_used": "If you have SMTP settings, it will be used.",
     "smtp_but_aws": "If you do not have SMTP settings but AWS settings,  e-mails will be sent by SES.",
     "neihter_of": "If neither is selected, then no email will be sent.",
@@ -35,6 +35,9 @@
     "host": "Host",
     "port": "Port",
     "user": "User",
+    "initialize_mail_settings": "initialize e-mail settings",
+    "initialize_mail_modal_header": "Initialize e-mail settings",
+    "confirm_to_initialize_mail_settings": "You can't restore to the current settings. Are you sure you want to initialize e-mail settings?",
     "aws_settings": "AWS settings",
     "aws_access": "This is for AWS settings. If you complete AWS settings, file upload function, profile picture function etc will be enabled.",
     "no_smtp_setting": "If you do not have SMTP settings, e-mails will be sent via SES. You need to verify from e-mail address and production settings.",

+ 25 - 7
resource/locales/en_US/translation.json

@@ -19,6 +19,7 @@
   "Tag": "Tag",
   "Tags": "Tags",
   "New": "New",
+  "Close": "Close",
   "Shortcuts": "Shortcuts",
   "eg": "e.g.",
   "add": "Add",
@@ -47,7 +48,6 @@
   "History": "History",
   "Presentation Mode": "Presentation",
   "Not available for guest": "Not available for guest",
-  "Shere this page link to public": "Shere this page link to public",
   "Create Archive Page": "Create Archive Page",
   "File type": "File type",
   "Target page": "Target page",
@@ -62,9 +62,6 @@
   "Last updated": "Updated",
   "Last_Login": "Last login",
   "Share": "Share",
-  "Share Link": "Share Link",
-  "share_link_notice":"remove {{count}} share links",
-  "delete_all_share_links":"Delete all share links",
   "Markdown Link": "Markdown Link",
   "Create/Edit Template": "Create/Edit template page",
   "Go to this version": "View this version",
@@ -201,6 +198,25 @@
     "password_is_not_set": "Password is not set"
   },
   "security_settings": "Security settings",
+  "share_links": {
+    "Shere this page link to public": "Shere this page link to public",
+    "share_link_list": "Share link list",
+    "share_link_management": "Share Link Management",
+    "No_share_links":"No share links",
+    "Share Link": "Share Link",
+    "Page Path": "Page Path",
+    "share_link_notice":"remove all share links",
+    "delete_all_share_links":"Delete all share links",
+    "expire": "Expiration",
+    "Days": "Days",
+    "Custom": "Custom",
+    "description": "description",
+    "enter_desc": "Enter description",
+    "Unlimited": "unlimited",
+    "Issue": "Issue",
+    "share_settings" :"Share settings",
+    "Invalid_Number_of_Date" : "You entered invalid value"
+  },
   "API Settings": "API settings",
   "API Token Settings": "API token settings",
   "Current API Token": "Current API token",
@@ -219,8 +235,8 @@
     "Copy to clipboard": "Copy to clipboard",
     "Page path": "Page path",
     "Page URL": "Page URL",
-    "Parmanent link": "Parmanent link",
-    "Page path and parmanent link": "Page path and parmanent link",
+    "Permanent link": "Permanent link",
+    "Page path and permanent link": "Page path and permanent link",
     "Markdown link": "Markdown link"
   },
   "search_help": {
@@ -262,7 +278,8 @@
       "restricted": "Access to this page is restricted",
       "stale": "More than {{count}} year has passed since last update.",
       "stale_plural": "More than {{count}} years has passed since last update.",
-      "expiration": "This share link will expire <strong>{{expiredAt}}</strong>."
+      "expiration": "This share link will expire at <strong>{{expiredAt}}</strong>.",
+      "no_deadline":"This page has no expiration date"
     }
   },
   "page_edit": {
@@ -349,6 +366,7 @@
   },
   "toaster": {
     "update_successed": "Succeeded to update {{target}}",
+    "initialize_successed": "Succeeded to initialize {{target}}",
     "give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin",
     "activate_user_success": "Succeeded to activating {{username}}",

+ 3 - 0
resource/locales/ja_JP/admin/admin.json

@@ -35,6 +35,9 @@
     "host": "ホスト",
     "port": "ポート",
     "user": "ユーザー",
+    "initialize_mail_settings": "設定を初期化",
+    "initialize_mail_modal_header": "メール設定の初期化",
+    "confirm_to_initialize_mail_settings": "一度初期化した設定は戻せません。本当に初期化しますか?",
     "aws_settings": "AWS設定",
     "aws_access": "AWS にアクセスするための設定を行います。AWS の設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。",
     "no_smtp_setting": "また、SMTP の設定が無い場合、SES を利用したメール送信が行われます。FromメールアドレスのVerify、プロダクション利用設定をする必要があります。",

+ 26 - 7
resource/locales/ja_JP/translation.json

@@ -19,6 +19,7 @@
   "Tag": "タグ",
   "Tags": "タグ",
   "New": "作成",
+  "Close": "閉じる",
   "Shortcuts": "ショートカット",
   "eg": "例:",
   "add": "追加",
@@ -30,6 +31,7 @@
   "User": "ユーザー",
   "status": "ステータス",
   "account_id": "アカウントID",
+  "Initialize": "初期化",
   "Update": "更新",
   "Update Page": "ページを更新",
   "Warning": "注意",
@@ -47,7 +49,6 @@
   "History": "更新履歴",
   "Presentation Mode": "プレゼンテーション",
   "Not available for guest": "ゲストユーザーは利用できません",
-  "Shere this page link to public": "外部に共有するリンクを発行する",
   "Create Archive Page": "アーカイブページの作成",
   "Target page": "対象ページ",
   "File type": "ファイル形式",
@@ -62,9 +63,6 @@
   "Last updated": "最終更新",
   "Last_Login": "最終ログイン",
   "Share": "共有",
-  "Share Link": "共有用リンク",
-  "share_link_notice":"{{count}} 件の共有リンクを削除します",
-  "delete_all_share_links":"全ての共有リンクを削除します",
   "Markdown Link": "Markdown形式のリンク",
   "Create/Edit Template": "テンプレートページの作成/編集",
   "Go to this version": "このバージョンを見る",
@@ -203,6 +201,25 @@
     "password_is_not_set": "パスワードが設定されていません"
   },
   "security_settings": "セキュリティ設定",
+  "share_links": {
+    "Shere this page link to public": "外部に共有するリンクを発行する",
+    "share_link_list": "共有リンクリスト",
+    "share_link_management": "共有リンク管理",
+    "No_share_links":"共有リンクが存在しません",
+    "Share Link": "共有用リンク",
+    "Page Path": "ページパス",
+    "share_link_notice":"共有リンクを全て削除します",
+    "delete_all_share_links":"全ての共有リンクを削除します",
+    "expire": "有効期限",
+    "Days": "日間",
+    "Custom": "カスタム",
+    "description": "概要",
+    "enter_desc": "概要を入力",
+    "Unlimited": "無期限",
+    "Issue": "発行",
+    "share_settings" :"共有設定",
+    "Invalid_Number_of_Date" : "有効期限の日数には整数を入力してください"
+  },
   "API Settings": "API設定",
   "API Token Settings": "API Token設定",
   "Current API Token": "現在のAPI Token",
@@ -221,8 +238,8 @@
     "Copy to clipboard": "クリップボードにコピー",
     "Page path": "ページ名",
     "Page URL": "ページURL",
-    "Parmanent link": "パーマリンク",
-    "Page path and parmanent link": "ページ名とパーマリンク",
+    "Permanent link": "パーマリンク",
+    "Page path and permanent link": "ページ名とパーマリンク",
     "Markdown link": "マークダウン形式のリンク"
   },
   "search_help": {
@@ -263,7 +280,8 @@
       "unlinked": "このページへのリダイレクトは削除されました。",
       "restricted": "このページの閲覧は制限されています",
       "stale": "このページは最終更新日から{{count}}年以上が経過しています。",
-      "expiration": "この共有パーマリンクの有効期限は <strong>{{expiredAt}}</strong> です。"
+      "expiration": "この共有パーマリンクの有効期限は <strong>{{expiredAt}}</strong> です。",
+      "no_deadline": "このページに有効期限は設定されていません。"
     }
   },
   "page_edit": {
@@ -350,6 +368,7 @@
   },
   "toaster": {
     "update_successed": "{{target}}を更新しました",
+    "initialize_successed": "{{target}}を初期化しました",
     "give_user_admin": "{{username}}を管理者に設定しました",
     "remove_user_admin": "{{username}}を管理者から外しました",
     "activate_user_success": "{{username}}を有効化しました",

+ 4 - 1
resource/locales/zh_CN/admin/admin.json

@@ -35,6 +35,9 @@
 		"host": "服务器",
 		"port": "端口号",
 		"user": "用户名",
+    "initialize_mail_settings": "初始化邮件设置",
+    "initialize_mail_modal_header": "初始化邮件设置",
+    "confirm_to_initialize_mail_settings": "You can't restore to the current settings. Are you sure you want to initialize e-mail settings?",
 		"aws_settings": "AWS设置",
 		"aws_access": "这是用于AWS设置的。如果您完成了AWS设置,文件上传功能,个人资料图片功能等将被启用。",
 		"no_smtp_setting": "如果您没有SMTP设置,电子邮件将通过SES发送。您需要从电子邮件地址和生产设置进行验证。",
@@ -315,4 +318,4 @@
 			"transfer_pages": "转移到另一组"
 		}
 	}
-}
+}

+ 29 - 8
resource/locales/zh_CN/translation.json

@@ -19,7 +19,8 @@
 	"administrator": "管理员",
 	"Tag": "标签",
 	"Tags": "Tags",
-	"New": "新建",
+  "New": "新建",
+  "Close": "Close",
 	"Shortcuts": "快捷方式",
 	"eg": "e.g.",
 	"add": "添加",
@@ -31,7 +32,8 @@
 	"User": "用户",
 	"status": "状态",
 	"account_id": "用户Id",
-	"Update": "更新",
+	"Initialize": "初始化",
+  "Update": "更新",
 	"Update Page": "更新本页",
 	"Warning": "警告",
 	"Sign in": "登录",
@@ -47,15 +49,13 @@
 	"Timeline View": "时间线",
 	"History": "历史",
 	"Presentation Mode": "演示文稿",
-	"Not available for guest": "Not available for guest",
+  "Not available for guest": "Not available for guest",
 	"username": "用户名",
 	"Created": "创建",
 	"Last updated": "上次更新",
-	"Last_Login": "上次登录",
+  "Last_Login": "上次登录",
 	"Share": "分享",
   "Share Link": "分享链接",
-  "share_link_notice":"remove {{count}} share links",
-  "delete_all_share_links":"Delete all share links",
 	"Markdown Link": "Markdown链接",
 	"Create/Edit Template": "创建/编辑 模板页面",
 	"Unportalize": "未启动",
@@ -249,7 +249,8 @@
 			"unlinked": "将网页重定向到此网页已被删除。",
 			"restricted": "访问此页受到限制",
 			"stale": "自上次更新以来,已超过{{count}年。",
-			"stale_plural": "自上次更新以来已过去{{count}年以上。"
+      "stale_plural": "自上次更新以来已过去{{count}年以上。",
+      "no_deadline": "This page has no expiration date"
 		}
 	},
 	"page_edit": {
@@ -335,6 +336,7 @@
 	},
 	"toaster": {
 		"update_successed": "Succeeded to update {{target}}",
+    "initialize_successed": "Succeeded to initialize {{target}}",
 		"give_user_admin": "Succeeded to give {{username}} admin",
 		"remove_user_admin": "Succeeded to remove {{username}} admin ",
 		"activate_user_success": "Succeeded to activating {{username}}",
@@ -409,7 +411,26 @@
 		"someone_editing": "Someone editing this page on HackMD",
 		"this_page_has_draft": "This page has a draft on HackMD"
 	},
-	"security_settings": "安全设置",
+  "security_settings": "安全设置",
+  "share_links": {
+    "Shere this page link to public": "Shere this page link to public",
+    "share_link_list": "Share link list",
+    "share_link_management": "Share Link Management",
+    "No_share_links":"No share links",
+    "Share Link": "Share Link",
+    "Page Path": "Page Path",
+    "share_link_notice":"remove all share links",
+    "delete_all_share_links":"Delete all share links",
+    "expire": "Expiration",
+    "Days": "Days",
+    "Custom": "Custom",
+    "description": "description",
+    "enter_desc": "Enter description",
+    "Unlimited": "unlimited",
+    "Issue": "Issue",
+    "share_settings" :"Share settings",
+    "Invalid_Number_of_Date" : "You entered invalid value"
+  },
 	"security_setting": {
 		"Security settings": "安全设置",
 		"Guest Users Access": "来宾用户访问",

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

@@ -141,7 +141,7 @@ $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
   ReactDOM.render(
     <I18nextProvider i18n={i18n}>
       <ErrorBoundary>
-        <PageHistory pageId={pageContainer.state.pageId} crowi={appContainer} />
+        <PageHistory shareLinkId={pageContainer.state.shareLinkId} pageId={pageContainer.state.pageId} crowi={appContainer} />
       </ErrorBoundary>
     </I18nextProvider>, document.getElementById('revision-history'),
   );

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

@@ -6,9 +6,9 @@ import Xss from '@commons/service/xss';
 import GrowiNavbar from './components/Navbar/GrowiNavbar';
 import GrowiNavbarBottom from './components/Navbar/GrowiNavbarBottom';
 import Sidebar from './components/Sidebar';
+import ShareLinkAlert from './components/Page/ShareLinkAlert';
 import HotkeysManager from './components/Hotkeys/HotkeysManager';
 import Fab from './components/Fab';
-import ShareLinkAlert from './components/Page/ShareLinkAlert';
 
 import AppContainer from './services/AppContainer';
 import SocketIoContainer from './services/SocketIoContainer';
@@ -46,11 +46,10 @@ const componentMappings = {
 
   'grw-sidebar-wrapper': <Sidebar />,
 
-  'grw-hotkeys-manager': <HotkeysManager />,
-
+  'share-link-alert': <ShareLinkAlert />,
   'grw-fab-container': <Fab />,
+  'grw-hotkeys-manager': <HotkeysManager />,
 
-  'share-link-alert': <ShareLinkAlert />,
 };
 
 export { appContainer, componentMappings };

+ 29 - 67
src/client/js/components/Admin/App/AppSettingsPage.jsx

@@ -1,92 +1,54 @@
-import React, { Fragment } from 'react';
-import { withTranslation } from 'react-i18next';
+import React, { Suspense } from 'react';
 import PropTypes from 'prop-types';
 import loggerFactory from '@alias/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastError } from '../../../util/apiNotification';
 
-import AppContainer from '../../../services/AppContainer';
 import AdminAppContainer from '../../../services/AdminAppContainer';
 
-import AppSetting from './AppSetting';
-import SiteUrlSetting from './SiteUrlSetting';
-import MailSetting from './MailSetting';
-import AwsSetting from './AwsSetting';
-import PluginSetting from './PluginSetting';
+import AppSettingsPageContents from './AppSettingsPageContents';
 
 const logger = loggerFactory('growi:appSettings');
 
-class AppSettingsPage extends React.Component {
-
-  async componentDidMount() {
-    const { adminAppContainer } = this.props;
-
-    try {
-      await adminAppContainer.retrieveAppSettingsData();
-    }
-    catch (err) {
-      toastError(err);
-      adminAppContainer.setState({ retrieveError: err.message });
-      logger.error(err);
-    }
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <Fragment>
+function AppSettingsPage(props) {
+  return (
+    <Suspense
+      fallback={(
         <div className="row">
-          <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('App Settings')}</h2>
-            <AppSetting />
-          </div>
-        </div>
-
-        <div className="row mt-5">
-          <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('Site URL settings')}</h2>
-            <SiteUrlSetting />
-          </div>
-        </div>
-
-        <div className="row mt-5">
-          <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('admin:app_setting.mail_settings')}</h2>
-            <MailSetting />
-          </div>
-        </div>
-
-        <div className="row mt-5">
-          <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('admin:app_setting.aws_settings')}</h2>
-            <AwsSetting />
-          </div>
+          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
         </div>
+)}
+    >
+      <RenderAppSettingsPageWrapper />
+    </Suspense>
+  );
+}
 
-        <div className="row mt-5">
-          <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('admin:app_setting.plugin_settings')}</h2>
-            <PluginSetting />
-          </div>
-        </div>
-      </Fragment>
-    );
+function RenderAppSettingsPage(props) {
+  if (props.adminAppContainer.state.title === props.adminAppContainer.dummyTitle) {
+    throw new Promise(async() => {
+      try {
+        await props.adminAppContainer.retrieveAppSettingsData();
+      }
+      catch (err) {
+        toastError(err);
+        props.adminAppContainer.setState({ retrieveError: err.message });
+        logger.error(err);
+      }
+    });
   }
 
+  return <AppSettingsPageContents />;
 }
 
-AppSettingsPage.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+RenderAppSettingsPage.propTypes = {
   adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
 };
 
 /**
  * Wrapper component for using unstated
  */
-const AppSettingsPageWrapper = withUnstatedContainers(AppSettingsPage, [AppContainer, AdminAppContainer]);
-
+const RenderAppSettingsPageWrapper = withUnstatedContainers(RenderAppSettingsPage, [AdminAppContainer]);
 
-export default withTranslation()(AppSettingsPageWrapper);
+export default AppSettingsPage;

+ 62 - 0
src/client/js/components/Admin/App/AppSettingsPageContents.jsx

@@ -0,0 +1,62 @@
+import React, { Fragment } from 'react';
+import { withTranslation } from 'react-i18next';
+import PropTypes from 'prop-types';
+
+import AppSetting from './AppSetting';
+import SiteUrlSetting from './SiteUrlSetting';
+import MailSetting from './MailSetting';
+import AwsSetting from './AwsSetting';
+import PluginSetting from './PluginSetting';
+
+class AppSettingsPageContents extends React.Component {
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Fragment>
+        <div className="row">
+          <div className="col-lg-12">
+            <h2 className="admin-setting-header">{t('App Settings')}</h2>
+            <AppSetting />
+          </div>
+        </div>
+
+        <div className="row mt-5">
+          <div className="col-lg-12">
+            <h2 className="admin-setting-header">{t('Site URL settings')}</h2>
+            <SiteUrlSetting />
+          </div>
+        </div>
+
+        <div className="row mt-5">
+          <div className="col-lg-12">
+            <h2 className="admin-setting-header">{t('admin:app_setting.mail_settings')}</h2>
+            <MailSetting />
+          </div>
+        </div>
+
+        <div className="row mt-5">
+          <div className="col-lg-12">
+            <h2 className="admin-setting-header">{t('admin:app_setting.aws_settings')}</h2>
+            <AwsSetting />
+          </div>
+        </div>
+
+        <div className="row mt-5">
+          <div className="col-lg-12">
+            <h2 className="admin-setting-header">{t('admin:app_setting.plugin_settings')}</h2>
+            <PluginSetting />
+          </div>
+        </div>
+      </Fragment>
+    );
+  }
+
+}
+
+AppSettingsPageContents.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+};
+
+export default withTranslation()(AppSettingsPageContents);

+ 65 - 2
src/client/js/components/Admin/App/MailSetting.jsx

@@ -3,12 +3,12 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import loggerFactory from '@alias/logger';
 
+import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../util/apiNotification';
 
 import AppContainer from '../../../services/AppContainer';
 import AdminAppContainer from '../../../services/AdminAppContainer';
-import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 const logger = loggerFactory('growi:appSettings');
 
@@ -17,7 +17,22 @@ class MailSetting extends React.Component {
   constructor(props) {
     super(props);
 
+    this.state = {
+      isInitializeValueModalOpen: false,
+    };
+
+    this.openInitializeValueModal = this.openInitializeValueModal.bind(this);
+    this.closeInitializeValueModal = this.closeInitializeValueModal.bind(this);
     this.submitHandler = this.submitHandler.bind(this);
+    this.initialize = this.initialize.bind(this);
+  }
+
+  openInitializeValueModal() {
+    this.setState({ isInitializeValueModalOpen: true });
+  }
+
+  closeInitializeValueModal() {
+    this.setState({ isInitializeValueModalOpen: false });
   }
 
   async submitHandler() {
@@ -33,6 +48,20 @@ class MailSetting extends React.Component {
     }
   }
 
+  async initialize() {
+    const { t, adminAppContainer } = this.props;
+
+    try {
+      await adminAppContainer.initializeMailSettingHandler();
+      toastSuccess(t('toaster.initialize_successed', { target: t('admin:app_setting.mail_settings') }));
+      this.closeInitializeValueModal();
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
   render() {
     const { t, adminAppContainer } = this.props;
 
@@ -94,7 +123,41 @@ class MailSetting extends React.Component {
           </div>
         </div>
 
-        <AdminUpdateButtonRow onClick={this.submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
+        <div className="row my-3">
+          <div className="offset-5">
+            <button type="button" className="btn btn-primary" onClick={this.submitHandler} disabled={adminAppContainer.state.retrieveError != null}>
+              { t('Update') }
+            </button>
+          </div>
+          <div className="offset-1">
+            <button
+              type="button"
+              className="btn btn-secondary"
+              onClick={this.openInitializeValueModal}
+              disabled={adminAppContainer.state.retrieveError != null}
+            >
+              {t('admin:app_setting.initialize_mail_settings')}
+            </button>
+          </div>
+        </div>
+        <Modal isOpen={this.state.isInitializeValueModalOpen} toggle={this.closeInitializeValueModal} className="initialize-mail-settings">
+          <ModalHeader tag="h4" toggle={this.closeInitializeValueModal} className="bg-danger text-light">
+            {t('admin:app_setting.initialize_mail_modal_header')}
+          </ModalHeader>
+          <ModalBody>
+            <div className="text-center mb-4">
+              {t('admin:app_setting.confirm_to_initialize_mail_settings')}
+            </div>
+            <div className="text-center my-2">
+              <button type="button" className="btn btn-outline-secondary mr-4" onClick={this.closeInitializeValueModal}>
+                {t('Cancel')}
+              </button>
+              <button type="button" className="btn btn-danger" onClick={this.initialize}>
+                {t('Initialize')}
+              </button>
+            </div>
+          </ModalBody>
+        </Modal>
       </React.Fragment>
     );
   }

+ 4 - 5
src/client/js/components/Admin/Security/DeleteAllShareLinksModal.jsx

@@ -37,17 +37,17 @@ const DeleteAllShareLinksModal = React.memo((props) => {
       <ModalHeader tag="h4" toggle={closeButtonHandler} className="bg-danger text-light">
         <span>
           <i className="icon-fw icon-fire"></i>
-          {t('delete_all_share_links')}
+          {t('share_links.delete_all_share_links')}
         </span>
       </ModalHeader>
       <ModalBody>
-        { t('share_link_notice', { count: props.count })}
+        { t('share_links.share_link_notice')}
       </ModalBody>
       <ModalFooter>
-        <Button onClick={closeButtonHandler}>Cancel</Button>
+        <Button onClick={closeButtonHandler}>{t('Cancel')}</Button>
         <Button color="danger" onClick={deleteAllLinkHandler}>
           <i className="icon icon-fire"></i>
-            Delete
+          {t('Delete')}
         </Button>
       </ModalFooter>
     </Modal>
@@ -60,7 +60,6 @@ DeleteAllShareLinksModal.propTypes = {
 
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func,
-  count: PropTypes.number.isRequired,
   onClickDeleteButton: PropTypes.func,
 };
 

+ 10 - 41
src/client/js/components/Admin/Security/ShareLinkSetting.jsx

@@ -1,7 +1,6 @@
 import React, { Fragment } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-import dateFnsFormat from 'date-fns/format';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../util/apiNotification';
@@ -12,6 +11,7 @@ import AppContainer from '../../../services/AppContainer';
 import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
 
 import DeleteAllShareLinksModal from './DeleteAllShareLinksModal';
+import ShareLinkList from '../../ShareLinkList';
 
 class ShareLinkSetting extends React.Component {
 
@@ -81,7 +81,7 @@ class ShareLinkSetting extends React.Component {
 
 
   render() {
-    const { adminGeneralSecurityContainer } = this.props;
+    const { t, adminGeneralSecurityContainer } = this.props;
 
     const pager = (
       <div className="pull-right my-3">
@@ -102,11 +102,11 @@ class ShareLinkSetting extends React.Component {
             type="button"
             onClick={this.showDeleteConfirmModal}
           >
-            Delete all links
+            {t('share_links.delete_all_share_links')}
           </button>
         )
         : (
-          <p className="pull-right mr-2">No share links</p>
+          <p className="pull-right mr-2">{t('share_links.No_share_links')}</p>
         )
     );
 
@@ -114,50 +114,19 @@ class ShareLinkSetting extends React.Component {
       <Fragment>
         <div className="mb-3">
           {deleteAllButton}
-          <h2 className="alert-anchor border-bottom">Shared Link List</h2>
+          <h2 className="alert-anchor border-bottom">{t('share_links.share_link_management')}</h2>
         </div>
 
         {pager}
-        <div className="table-responsive">
-          <table className="table table-bordered">
-            <thead>
-              <tr>
-                <th>Link</th>
-                <th>PagePath</th>
-                <th>Expiration</th>
-                <th>Description</th>
-                <th>Order</th>
-              </tr>
-            </thead>
-            <tbody>
-              {adminGeneralSecurityContainer.state.shareLinks.map((sharelink) => {
-                return (
-                  <tr key={sharelink._id}>
-                    <td>{sharelink._id}</td>
-                    <td><a href={sharelink.relatedPage.path}>{sharelink.relatedPage.path}</a></td>
-                    <td>{sharelink.expiredAt && <span>{dateFnsFormat(new Date(sharelink.expiredAt), 'yyyy-MM-dd HH:mm')}</span>}</td>
-                    <td>{sharelink.description}</td>
-                    <td>
-                      <button
-                        className="btn btn-outline-warning"
-                        type="button"
-                        shareLinks={sharelink._id}
-                        onClick={() => { this.deleteLinkById(sharelink._id) }}
-                      >
-                        <i className="icon-trash mr-2"></i>Delete
-                      </button>
-                    </td>
-                  </tr>
-                );
-              })}
-            </tbody>
-          </table>
-        </div>
+        <ShareLinkList
+          shareLinks={adminGeneralSecurityContainer.state.shareLinks}
+          onClickDeleteButton={this.deleteLinkById}
+          isAdmin
+        />
 
         <DeleteAllShareLinksModal
           isOpen={this.state.isDeleteConfirmModalShown}
           onClose={this.closeDeleteConfirmModal}
-          count={adminGeneralSecurityContainer.state.shareLinks.length}
           onClickDeleteButton={this.deleteAllLinksButtonHandler}
         />
 

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

@@ -66,7 +66,7 @@ class BookmarkButton extends React.Component {
         onClick={this.handleClick}
         className={`btn rounded-circle btn-bookmark border-0 d-edit-none
           ${`btn-${this.props.size}`}
-          ${this.state.isBookmarked ? 'btn-warning active' : 'btn-outline-warning'}`}
+          ${this.state.isBookmarked ? 'active' : ''}`}
       >
         <i className="icon-star"></i>
       </button>

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

@@ -44,7 +44,7 @@ class LikeButton extends React.Component {
         type="button"
         onClick={this.handleClick}
         className={`btn rounded-circle btn-like border-0 d-edit-none
-        ${this.state.isLiked ? 'btn-info active' : 'btn-outline-info'}`}
+        ${this.state.isLiked ? 'active' : ''}`}
       >
         <i className="icon-like"></i>
       </button>

+ 9 - 7
src/client/js/components/OutsideShareLinkModal.jsx

@@ -87,16 +87,18 @@ class OutsideShareLinkModal extends React.Component {
   }
 
   render() {
+    const { t } = this.props;
+
     return (
-      <Modal size="lg" isOpen={this.props.isOpen} toggle={this.props.onClose}>
-        <ModalHeader tag="h4" toggle={this.props.onClose} className="bg-primary text-light">Title
+      <Modal size="xl" isOpen={this.props.isOpen} toggle={this.props.onClose}>
+        <ModalHeader tag="h4" toggle={this.props.onClose} className="bg-primary text-light">{t('share_links.Shere this page link to public')}
         </ModalHeader>
         <ModalBody>
           <div className="container">
-            <div className="form-inline mb-3">
-              <h4>Shared Link List</h4>
-              <button className="ml-auto btn btn-danger" type="button" onClick={this.deleteAllLinksButtonHandler}>Delete all links</button>
-            </div>
+            <h3 className="grw-modal-head  d-flex  pb-2">
+              { t('share_links.share_link_list') }
+              <button className="btn btn-danger ml-auto " type="button" onClick={this.deleteAllLinksButtonHandler}>{t('delete_all')}</button>
+            </h3>
 
             <div>
               <ShareLinkList
@@ -108,7 +110,7 @@ class OutsideShareLinkModal extends React.Component {
                 type="button"
                 onClick={this.toggleShareLinkFormHandler}
               >
-                {this.state.isOpenShareLinkForm ? 'Close' : 'New'}
+                {this.state.isOpenShareLinkForm ? t('Close') : t('New')}
               </button>
               {this.state.isOpenShareLinkForm && <ShareLinkForm onCloseForm={this.toggleShareLinkFormHandler} />}
             </div>

+ 3 - 0
src/client/js/components/Page.jsx

@@ -9,6 +9,7 @@ import EditorContainer from '../services/EditorContainer';
 
 import MarkdownTable from '../models/MarkdownTable';
 
+import LinkEditModal from './PageEditor/LinkEditModal';
 import RevisionRenderer from './Page/RevisionRenderer';
 import HandsontableModal from './PageEditor/HandsontableModal';
 import DrawioModal from './PageEditor/DrawioModal';
@@ -29,6 +30,7 @@ class Page extends React.Component {
 
     this.growiRenderer = this.props.appContainer.getRenderer('page');
 
+    this.linkEditModal = React.createRef();
     this.handsontableModal = React.createRef();
     this.drawioModal = React.createRef();
 
@@ -137,6 +139,7 @@ class Page extends React.Component {
 
         { isLoggedIn && (
           <>
+            <LinkEditModal ref={this.LinkEditModal} />
             <HandsontableModal ref={this.handsontableModal} onSave={this.saveHandlerForHandsontableModal} />
             <DrawioModal ref={this.drawioModal} onSave={this.saveHandlerForDrawioModal} />
           </>

+ 22 - 13
src/client/js/components/Page/CopyDropdown.jsx

@@ -66,11 +66,14 @@ class CopyDropdown extends React.Component {
   }
 
   generatePermalink() {
-    const { pageId } = this.props;
+    const { pageId, isShareLinkMode } = this.props;
 
     if (pageId == null) {
       return null;
     }
+    if (isShareLinkMode) {
+      return decodeURI(`${origin}/share/${pageId}`);
+    }
 
     return decodeURI(`${origin}/${pageId}${this.uriParams}`);
   }
@@ -92,30 +95,36 @@ class CopyDropdown extends React.Component {
   );
 
   render() {
-    const { t, pageId } = this.props;
+    const {
+      t, pageId, isShareLinkMode,
+    } = this.props;
     const { isParamsAppended } = this.state;
 
     const pagePathWithParams = this.generatePagePathWithParams();
     const pagePathUrl = this.generatePagePathUrl();
     const permalink = this.generatePermalink();
 
+    const copyTarget = isShareLinkMode ? `copyShareLink${pageId}` : 'copyPagePathDropdown';
+    const dropdownToggleStyle = isShareLinkMode ? 'btn btn-secondary' : 'd-block text-muted bg-transparent btn-copy border-0';
+
     const { id, DropdownItemContents } = this;
 
     const customSwitchForParamsId = `customSwitchForParams_${id}`;
 
     return (
       <>
-        <UncontrolledDropdown id="copyPagePathDropdown" className="grw-copy-dropdown">
-
+        <UncontrolledDropdown id={copyTarget} className="grw-copy-dropdown">
           <DropdownToggle
             caret
-            className="d-block text-muted bg-transparent btn-copy border-0"
+            className={dropdownToggleStyle}
             style={this.props.buttonStyle}
           >
-            <i className="ti-clipboard"></i>
+            { isShareLinkMode ? (
+              <>Copy Link</>
+            ) : (<i className="ti-clipboard"></i>)}
           </DropdownToggle>
 
-          <DropdownMenu>
+          <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: null } }}>
 
             <div className="d-flex align-items-center justify-content-between">
               <DropdownItem header className="px-3">
@@ -150,25 +159,24 @@ class CopyDropdown extends React.Component {
                 <DropdownItemContents title={t('copy_to_clipboard.Page URL')} contents={pagePathUrl} />
               </DropdownItem>
             </CopyToClipboard>
-
             <DropdownItem divider className="my-0"></DropdownItem>
 
-            {/* Parmanent Link */}
+            {/* Permanent Link */}
             { pageId && (
               <CopyToClipboard text={permalink} onCopy={this.showToolTip}>
                 <DropdownItem className="px-3">
-                  <DropdownItemContents title={t('copy_to_clipboard.Parmanent link')} contents={permalink} />
+                  <DropdownItemContents title={t('copy_to_clipboard.Permanent link')} contents={permalink} />
                 </DropdownItem>
               </CopyToClipboard>
             )}
 
             <DropdownItem divider className="my-0"></DropdownItem>
 
-            {/* Page path + Parmanent Link */}
+            {/* Page path + Permanent Link */}
             { pageId && (
               <CopyToClipboard text={`${pagePathWithParams}\n${permalink}`} onCopy={this.showToolTip}>
                 <DropdownItem className="px-3">
-                  <DropdownItemContents title={t('copy_to_clipboard.Page path and parmanent link')} contents={<>{pagePathWithParams}<br />{permalink}</>} />
+                  <DropdownItemContents title={t('copy_to_clipboard.Page path and permanent link')} contents={<>{pagePathWithParams}<br />{permalink}</>} />
                 </DropdownItem>
               </CopyToClipboard>
             )}
@@ -187,7 +195,7 @@ class CopyDropdown extends React.Component {
 
         </UncontrolledDropdown>
 
-        <Tooltip placement="bottom" isOpen={this.state.tooltipOpen} target="copyPagePathDropdown" fade={false}>
+        <Tooltip placement="bottom" isOpen={this.state.tooltipOpen} target={copyTarget} fade={false}>
           copied!
         </Tooltip>
       </>
@@ -202,6 +210,7 @@ CopyDropdown.propTypes = {
   pagePath: PropTypes.string.isRequired,
   pageId: PropTypes.string,
   buttonStyle: PropTypes.object,
+  isShareLinkMode: PropTypes.bool,
 };
 
 export default withTranslation()(CopyDropdown);

+ 38 - 11
src/client/js/components/Page/PageManagement.jsx

@@ -1,5 +1,6 @@
 import React, { useState } from 'react';
 import PropTypes from 'prop-types';
+import { UncontrolledTooltip } from 'reactstrap';
 import { withTranslation } from 'react-i18next';
 
 import { isTopPage } from '@commons/util/path-utils';
@@ -83,6 +84,10 @@ const PageManagement = (props) => {
   }
 
   function renderModals() {
+    if (currentUser == null) {
+      return null;
+    }
+
     return (
       <>
         <PageRenameModal
@@ -108,19 +113,41 @@ const PageManagement = (props) => {
     );
   }
 
+  function renderDotsIconForCurrentUser() {
+    return (
+      <>
+        <button
+          type="button"
+          className="btn-link nav-link bg-transparent dropdown-toggle dropdown-toggle-no-caret"
+          data-toggle="dropdown"
+        >
+          <i className="icon-options-vertical"></i>
+        </button>
+      </>
+    );
+  }
+
+  function renderDotsIconForGuestUser() {
+    return (
+      <>
+        <button
+          type="button"
+          className="btn nav-link bg-transparent dropdown-toggle dropdown-toggle-no-caret disabled"
+          id="icon-options-guest-tltips"
+        >
+          <i className="icon-options-vertical"></i>
+        </button>
+        <UncontrolledTooltip placement="top" target="icon-options-guest-tltips">
+          {t('Not available for guest')}
+        </UncontrolledTooltip>
+      </>
+    );
+  }
+
+
   return (
     <>
-      <a
-        role="button"
-        className={`nav-link dropdown-toggle dropdown-toggle-no-caret ${currentUser == null && 'dropdown-toggle-disabled'}`}
-        href="#"
-        data-toggle={`${currentUser == null ? 'tooltip' : 'dropdown'}`}
-        data-placement="top"
-        data-container="body"
-        title={t('Not available for guest')}
-      >
-        <i className="icon-options-vertical"></i>
-      </a>
+      {currentUser == null ? renderDotsIconForGuestUser() : renderDotsIconForCurrentUser()}
       <div className="dropdown-menu dropdown-menu-right">
         {!isTopPagePath && renderDropdownItemForNotTopPage()}
         <button className="dropdown-item" type="button" onClick={openPageTemplateModalHandler}>

+ 5 - 2
src/client/js/components/Page/PageShareManagement.jsx

@@ -76,6 +76,10 @@ const PageShareManagement = (props) => {
 
 
   function renderModals() {
+    if (currentUser == null) {
+      return null;
+    }
+
     return (
       <>
         <OutsideShareLinkModal
@@ -131,8 +135,7 @@ const PageShareManagement = (props) => {
       {currentUser == null ? renderGuestUser() : renderCurrentUser()}
       <div className="dropdown-menu dropdown-menu-right">
         <button className="dropdown-item" type="button" onClick={openOutsideShareLinkModalHandler}>
-          <i className="icon-fw icon-link"></i>
-          {t('Shere this page link to public')}
+          <i className="icon-fw icon-link"></i>{t('share_links.Shere this page link to public')}
           <span className="ml-2 badge badge-info badge-pill">{pageContainer.state.shareLinksNumber}</span>
         </button>
         <button

+ 18 - 5
src/client/js/components/Page/ShareLinkAlert.jsx

@@ -5,12 +5,23 @@ import { withTranslation } from 'react-i18next';
 
 const ShareLinkAlert = (props) => {
   const { t } = props;
+
+
   const shareContent = document.getElementById('is-shared-page');
   const expiredAt = shareContent.getAttribute('data-share-link-expired-at');
   const createdAt = shareContent.getAttribute('data-share-link-created-at');
-  const wholeTime = new Date(expiredAt).getTime() - new Date(createdAt).getTime();
-  const remainingTime = new Date(expiredAt).getTime() - new Date().getTime();
-  const ratio = remainingTime / wholeTime;
+
+  function generateRatio() {
+    const wholeTime = new Date(expiredAt).getTime() - new Date(createdAt).getTime();
+    const remainingTime = new Date(expiredAt).getTime() - new Date().getTime();
+    return remainingTime / wholeTime;
+  }
+
+  let ratio = 1;
+
+  if (expiredAt !== '') {
+    ratio = generateRatio();
+  }
 
   function specifyColor() {
     let color;
@@ -32,8 +43,10 @@ const ShareLinkAlert = (props) => {
   return (
     <p className={`alert alert-${specifyColor()} py-3 px-4`}>
       <i className="icon-fw icon-link"></i>
-      {/* eslint-disable-next-line react/no-danger */}
-      <span dangerouslySetInnerHTML={{ __html: t('page_page.notice.expiration', { expiredAt }) }} />
+      {(expiredAt === '' ? <span>{t('page_page.notice.no_deadline')}</span>
+      // eslint-disable-next-line react/no-danger
+      : <span dangerouslySetInnerHTML={{ __html: t('page_page.notice.expiration', { expiredAt }) }} />
+      )}
     </p>
   );
 };

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

@@ -162,7 +162,6 @@ const PageCreateModal = (props) => {
               {isReachable
                 ? (
                   <PagePathAutoComplete
-                    crowi={appContainer}
                     initializedPath={pathname}
                     addTrailingSlash
                     onSubmit={ppacSubmitHandler}

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

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

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

@@ -16,8 +16,10 @@ import pasteHelper from './PasteHelper';
 import EmojiAutoCompleteHelper from './EmojiAutoCompleteHelper';
 import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
 import MarkdownTableInterceptor from './MarkdownTableInterceptor';
+import mlu from './MarkdownLinkUtil';
 import mtu from './MarkdownTableUtil';
 import mdu from './MarkdownDrawioUtil';
+import LinkEditModal from './LinkEditModal';
 import HandsontableModal from './HandsontableModal';
 import EditorIcon from './EditorIcon';
 import DrawioModal from './DrawioModal';
@@ -54,6 +56,7 @@ require('../../util/codemirror/autorefresh.ext');
 
 
 const MARKDOWN_TABLE_ACTIVATED_CLASS = 'markdown-table-activated';
+const MARKDOWN_LINK_ACTIVATED_CLASS = 'markdown-link-activated';
 
 export default class CodeMirrorEditor extends AbstractEditor {
 
@@ -71,6 +74,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
       additionalClassSet: new Set(),
     };
 
+    this.linkEditModal = React.createRef();
     this.handsontableModal = React.createRef();
     this.drawioModal = React.createRef();
 
@@ -98,6 +102,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
     this.renderCheatsheetModalButton = this.renderCheatsheetModalButton.bind(this);
 
     this.makeHeaderHandler = this.makeHeaderHandler.bind(this);
+    this.showLinkEditHandler = this.showLinkEditHandler.bind(this);
     this.showHandsonTableHandler = this.showHandsonTableHandler.bind(this);
     this.showDrawioHandler = this.showDrawioHandler.bind(this);
   }
@@ -462,8 +467,10 @@ export default class CodeMirrorEditor extends AbstractEditor {
   cursorHandler(editor, event) {
     const { additionalClassSet } = this.state;
     const hasCustomClass = additionalClassSet.has(MARKDOWN_TABLE_ACTIVATED_CLASS);
+    const hasLinkClass = additionalClassSet.has(MARKDOWN_LINK_ACTIVATED_CLASS);
 
     const isInTable = mtu.isInTable(editor);
+    const isInLink = mlu.isInLink(editor);
 
     if (!hasCustomClass && isInTable) {
       additionalClassSet.add(MARKDOWN_TABLE_ACTIVATED_CLASS);
@@ -474,6 +481,16 @@ export default class CodeMirrorEditor extends AbstractEditor {
       additionalClassSet.delete(MARKDOWN_TABLE_ACTIVATED_CLASS);
       this.setState({ additionalClassSet });
     }
+
+    if (!hasLinkClass && isInLink) {
+      additionalClassSet.add(MARKDOWN_LINK_ACTIVATED_CLASS);
+      this.setState({ additionalClassSet });
+    }
+
+    if (hasLinkClass && !isInLink) {
+      additionalClassSet.delete(MARKDOWN_LINK_ACTIVATED_CLASS);
+      this.setState({ additionalClassSet });
+    }
   }
 
   changeHandler(editor, data, value) {
@@ -649,6 +666,10 @@ export default class CodeMirrorEditor extends AbstractEditor {
     cm.focus();
   }
 
+  showLinkEditHandler() {
+    this.linkEditModal.current.show(mlu.getMarkdownLink(this.getCodeMirror()));
+  }
+
   showHandsonTableHandler() {
     this.handsontableModal.current.show(mtu.getMarkdownTable(this.getCodeMirror()));
   }
@@ -745,7 +766,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         color={null}
         size="sm"
         title="Link"
-        onClick={this.createReplaceSelectionHandler('[', ']()')}
+        onClick={this.showLinkEditHandler}
       >
         <EditorIcon icon="Link" />
       </Button>,
@@ -849,6 +870,10 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
         { this.renderCheatsheetOverlay() }
 
+        <LinkEditModal
+          ref={this.linkEditModal}
+          onSave={(link) => { return mlu.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), link) }}
+        />
         <HandsontableModal
           ref={this.handsontableModal}
           onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}

+ 353 - 0
src/client/js/components/PageEditor/LinkEditModal.jsx

@@ -0,0 +1,353 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import {
+  Modal,
+  ModalHeader,
+  ModalBody,
+  ModalFooter,
+} from 'reactstrap';
+
+import { debounce } from 'throttle-debounce';
+
+import path from 'path';
+import Preview from './Preview';
+
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
+
+import SearchTypeahead from '../SearchTypeahead';
+import Linker from '../../models/Linker';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+class LinkEditModal extends React.PureComponent {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      show: false,
+      isUseRelativePath: false,
+      isUsePermanentLink: false,
+      linkInputValue: '',
+      labelInputValue: '',
+      linkerType: Linker.types.markdownLink,
+      markdown: '',
+      permalink: '',
+    };
+
+    this.isApplyPukiwikiLikeLinkerPlugin = window.growiRenderer.preProcessors.some(process => process.constructor.name === 'PukiwikiLikeLinker');
+
+    this.show = this.show.bind(this);
+    this.hide = this.hide.bind(this);
+    this.cancel = this.cancel.bind(this);
+    this.handleChangeTypeahead = this.handleChangeTypeahead.bind(this);
+    this.handleChangeLabelInput = this.handleChangeLabelInput.bind(this);
+    this.handleChangeLinkInput = this.handleChangeLinkInput.bind(this);
+    this.handleSelecteLinkerType = this.handleSelecteLinkerType.bind(this);
+    this.toggleIsUseRelativePath = this.toggleIsUseRelativePath.bind(this);
+    this.toggleIsUsePamanentLink = this.toggleIsUsePamanentLink.bind(this);
+    this.save = this.save.bind(this);
+    this.generateLink = this.generateLink.bind(this);
+    this.renderPreview = this.renderPreview.bind(this);
+    this.getRootPath = this.getRootPath.bind(this);
+
+    this.getPreviewDebounced = debounce(200, this.getPreview.bind(this));
+  }
+
+  componentDidUpdate(prevState) {
+    const { linkInputValue: prevLinkInputValue } = prevState;
+    const { linkInputValue } = this.state;
+    if (linkInputValue !== prevLinkInputValue) {
+      this.getPreviewDebounced(linkInputValue);
+    }
+  }
+
+  // defaultMarkdownLink is an instance of Linker
+  show(defaultMarkdownLink = null) {
+    // if defaultMarkdownLink is null, set default value in inputs.
+    const { label = '' } = defaultMarkdownLink;
+    let { link = '', type = Linker.types.markdownLink } = defaultMarkdownLink;
+
+    // if type of defaultMarkdownLink is pukiwikiLink when pukiwikiLikeLinker plugin is disable, change type(not change label and link)
+    if (type === Linker.types.pukiwikiLink && !this.isApplyPukiwikiLikeLinkerPlugin) {
+      type = Linker.types.markdownLink;
+    }
+
+    const url = new URL(link, 'http://example.com');
+    const isUseRelativePath = url.origin === 'http://example.com' && !link.startsWith('/') && link !== '';
+    if (isUseRelativePath) {
+      const rootPath = this.getRootPath(type);
+      link = path.resolve(rootPath, link);
+    }
+
+    this.setState({
+      show: true,
+      labelInputValue: label,
+      linkInputValue: link,
+      isUsePermanentLink: false,
+      permalink: '',
+      linkerType: type,
+      isUseRelativePath,
+    });
+  }
+
+  cancel() {
+    this.hide();
+  }
+
+  hide() {
+    this.setState({
+      show: false,
+    });
+  }
+
+  toggleIsUseRelativePath() {
+    if (!this.state.linkInputValue.startsWith('/') || this.state.linkerType === Linker.types.growiLink) {
+      return;
+    }
+
+    // User can't use both relativePath and permalink at the same time
+    this.setState({ isUseRelativePath: !this.state.isUseRelativePath, isUsePermanentLink: false });
+  }
+
+  toggleIsUsePamanentLink() {
+    if (this.state.permalink === '' || this.state.linkerType === Linker.types.growiLink) {
+      return;
+    }
+
+    // User can't use both relativePath and permalink at the same time
+    this.setState({ isUsePermanentLink: !this.state.isUsePermanentLink, isUseRelativePath: false });
+  }
+
+  renderPreview() {
+    return (
+      <div className="linkedit-preview">
+        <Preview
+          markdown={this.state.markdown}
+        />
+      </div>
+    );
+  }
+
+  async getPreview(path) {
+    let markdown = '';
+    let permalink = '';
+    try {
+      const res = await this.props.appContainer.apiGet('/pages.get', { path });
+      markdown = res.page.revision.body;
+      permalink = `${window.location.origin}/${res.page.id}`;
+    }
+    catch (err) {
+      markdown = `<div class="alert alert-warning" role="alert"><strong>${err.message}</strong></div>`;
+    }
+    this.setState({ markdown, permalink });
+  }
+
+  handleChangeTypeahead(selected) {
+    const page = selected[0];
+    if (page != null) {
+      this.setState({ linkInputValue: page.path });
+    }
+  }
+
+  handleChangeLabelInput(label) {
+    this.setState({ labelInputValue: label });
+  }
+
+  handleChangeLinkInput(link) {
+    let isUseRelativePath = this.state.isUseRelativePath;
+    if (!this.state.linkInputValue.startsWith('/') || this.state.linkerType === Linker.types.growiLink) {
+      isUseRelativePath = false;
+    }
+    this.setState({ linkInputValue: link, isUseRelativePath, isUsePermanentLink: false });
+  }
+
+  handleSelecteLinkerType(linkerType) {
+    let { isUseRelativePath, isUsePermanentLink } = this.state;
+    if (linkerType === Linker.types.growiLink) {
+      isUseRelativePath = false;
+      isUsePermanentLink = false;
+    }
+    this.setState({ linkerType, isUseRelativePath, isUsePermanentLink });
+  }
+
+  save() {
+    const output = this.generateLink();
+
+    if (this.props.onSave != null) {
+      this.props.onSave(output);
+    }
+
+    this.hide();
+  }
+
+  generateLink() {
+    const {
+      linkInputValue,
+      labelInputValue,
+      linkerType,
+      isUseRelativePath,
+      isUsePermanentLink,
+      permalink,
+    } = this.state;
+
+    let reshapedLink = linkInputValue;
+    if (isUseRelativePath) {
+      const rootPath = this.getRootPath(linkerType);
+      reshapedLink = rootPath === linkInputValue ? '.' : path.relative(rootPath, linkInputValue);
+    }
+
+    return new Linker(
+      linkerType,
+      labelInputValue,
+      reshapedLink,
+      isUsePermanentLink,
+      permalink,
+    );
+  }
+
+  getRootPath(type) {
+    const { pageContainer } = this.props;
+    const pagePath = pageContainer.state.path;
+    // rootPaths of md link and pukiwiki link are different
+    return type === Linker.types.markdownLink ? path.dirname(pagePath) : pagePath;
+  }
+
+  render() {
+    return (
+      <Modal isOpen={this.state.show} toggle={this.cancel} size="lg">
+        <ModalHeader tag="h4" toggle={this.cancel} className="bg-primary text-light">
+          Edit Links
+        </ModalHeader>
+
+        <ModalBody className="container">
+          <div className="row">
+            <div className="col-12 col-lg-6">
+              <form className="form-group">
+                <div className="form-gorup my-3">
+                  <label htmlFor="linkInput">Link</label>
+                  <div className="input-group">
+                    <SearchTypeahead
+                      onChange={this.handleChangeTypeahead}
+                      onInputChange={this.handleChangeLinkInput}
+                      inputName="link"
+                      placeholder="Input page path or URL"
+                      keywordOnInit={this.state.linkInputValue}
+                    />
+                  </div>
+                </div>
+              </form>
+
+              <div className="d-block d-lg-none mb-3 overflow-auto">
+                {this.renderPreview()}
+              </div>
+
+              <div className="card">
+                <div className="card-body">
+                  <form className="form-group">
+                    <div className="form-group btn-group d-flex" role="group" aria-label="type">
+                      <button
+                        type="button"
+                        name={Linker.types.markdownLink}
+                        className={`btn btn-outline-secondary col ${this.state.linkerType === Linker.types.markdownLink && 'active'}`}
+                        onClick={e => this.handleSelecteLinkerType(e.target.name)}
+                      >
+                        Markdown
+                      </button>
+                      <button
+                        type="button"
+                        name={Linker.types.growiLink}
+                        className={`btn btn-outline-secondary col ${this.state.linkerType === Linker.types.growiLink && 'active'}`}
+                        onClick={e => this.handleSelecteLinkerType(e.target.name)}
+                      >
+                        Growi Original
+                      </button>
+                      {this.isApplyPukiwikiLikeLinkerPlugin && (
+                        <button
+                          type="button"
+                          name={Linker.types.pukiwikiLink}
+                          className={`btn btn-outline-secondary col ${this.state.linkerType === Linker.types.pukiwikiLink && 'active'}`}
+                          onClick={e => this.handleSelecteLinkerType(e.target.name)}
+                        >
+                          Pukiwiki
+                        </button>
+                      )}
+                    </div>
+
+                    <div className="form-group">
+                      <label htmlFor="label">Label</label>
+                      <input
+                        type="text"
+                        className="form-control"
+                        id="label"
+                        value={this.state.labelInputValue}
+                        onChange={e => this.handleChangeLabelInput(e.target.value)}
+                        disabled={this.state.linkerType === Linker.types.growiLink}
+                      />
+                    </div>
+                    <div className="form-inline">
+                      <div className="custom-control custom-checkbox custom-checkbox-info">
+                        <input
+                          className="custom-control-input"
+                          id="relativePath"
+                          type="checkbox"
+                          checked={this.state.isUseRelativePath}
+                          disabled={!this.state.linkInputValue.startsWith('/') || this.state.linkerType === Linker.types.growiLink}
+                        />
+                        <label className="custom-control-label" htmlFor="relativePath" onClick={this.toggleIsUseRelativePath}>
+                          Use relative path
+                        </label>
+                      </div>
+                    </div>
+                    <div className="form-inline">
+                      <div className="custom-control custom-checkbox custom-checkbox-info">
+                        <input
+                          className="custom-control-input"
+                          id="permanentLink"
+                          type="checkbox"
+                          checked={this.state.isUsePermanentLink}
+                          disabled={this.state.permalink === '' || this.state.linkerType === Linker.types.growiLink}
+                        />
+                        <label className="custom-control-label" htmlFor="permanentLink" onClick={this.toggleIsUsePamanentLink}>
+                          Use permanent link
+                        </label>
+                      </div>
+                    </div>
+                  </form>
+                </div>
+              </div>
+            </div>
+
+            <div className="col d-none d-lg-block pr-0 mr-3 overflow-auto">
+              {this.renderPreview()}
+            </div>
+          </div>
+        </ModalBody>
+        <ModalFooter>
+          <button type="button" className="btn btn-sm btn-outline-secondary" onClick={this.hide}>
+            Cancel
+          </button>
+          <button type="submit" className="btn btn-sm btn-primary" onClick={this.save}>
+            Done
+          </button>
+        </ModalFooter>
+      </Modal>
+    );
+  }
+
+}
+
+LinkEditModal.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  onSave: PropTypes.func,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const LinkEditModalWrapper = withUnstatedContainers(LinkEditModal, [AppContainer, PageContainer]);
+
+export default LinkEditModalWrapper;

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

@@ -0,0 +1,48 @@
+import Linker from '../../models/Linker';
+
+/**
+ * Utility for markdown link
+ */
+class MarkdownLinkUtil {
+
+  constructor() {
+    this.getMarkdownLink = this.getMarkdownLink.bind(this);
+    this.isInLink = this.isInLink.bind(this);
+    this.replaceFocusedMarkdownLinkWithEditor = this.replaceFocusedMarkdownLinkWithEditor.bind(this);
+  }
+
+  // return an instance of Linker from cursor position or selected text.
+  getMarkdownLink(editor) {
+    if (!this.isInLink(editor)) {
+      return Linker.fromMarkdownString(editor.getDoc().getSelection());
+    }
+    const curPos = editor.getCursor();
+    return Linker.fromLineWithIndex(editor.getDoc().getLine(curPos.line), curPos.ch);
+  }
+
+  isInLink(editor) {
+    const curPos = editor.getCursor();
+    const { beginningOfLink, endOfLink } = Linker.getBeginningAndEndIndexOfLink(editor.getDoc().getLine(curPos.line), curPos.ch);
+    return beginningOfLink >= 0 && endOfLink >= 0;
+  }
+
+  // replace link(link is an instance of Linker)
+  replaceFocusedMarkdownLinkWithEditor(editor, link) {
+    const curPos = editor.getCursor();
+    const linkStr = link.generateMarkdownText();
+    if (!this.isInLink(editor)) {
+      editor.getDoc().replaceSelection(linkStr);
+    }
+    else {
+      const line = editor.getDoc().getLine(curPos.line);
+      const { beginningOfLink, endOfLink } = Linker.getBeginningAndEndIndexOfLink(line, curPos.ch);
+      editor.getDoc().replaceRange(linkStr, { line: curPos.line, ch: beginningOfLink }, { line: curPos.line, ch: endOfLink });
+    }
+  }
+
+}
+
+// singleton pattern
+const instance = new MarkdownLinkUtil();
+Object.freeze(instance);
+export default instance;

+ 7 - 2
src/client/js/components/PageHistory.jsx

@@ -26,6 +26,7 @@ class PageHistory extends React.Component {
 
   async componentWillMount() {
     const pageId = this.props.pageId;
+    const shareLinkId = this.props.shareLinkId || null;
 
     if (!pageId) {
       return;
@@ -34,7 +35,7 @@ class PageHistory extends React.Component {
     let res;
     try {
       this.setState({ isLoading: true });
-      res = await this.props.crowi.apiGet('/revisions.ids', { page_id: pageId });
+      res = await this.props.crowi.apiGet('/revisions.ids', { page_id: pageId, share_link_id: shareLinkId });
     }
     catch (err) {
       logger.error(err);
@@ -110,12 +111,14 @@ class PageHistory extends React.Component {
   }
 
   fetchPageRevisionBody(revision) {
+    const shareLinkId = this.props.shareLinkId || null;
+
     if (revision.body) {
       return;
     }
 
     this.props.crowi.apiGet('/revisions.get',
-      { page_id: this.props.pageId, revision_id: revision._id })
+      { page_id: this.props.pageId, revision_id: revision._id, share_link_id: shareLinkId })
       .then((res) => {
         if (res.ok) {
           this.setState({
@@ -166,6 +169,8 @@ class PageHistory extends React.Component {
 
 PageHistory.propTypes = {
   t: PropTypes.func.isRequired, // i18next
+
+  shareLinkId: PropTypes.string,
   pageId: PropTypes.string,
   crowi: PropTypes.object.isRequired,
 };

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

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

+ 95 - 81
src/client/js/components/ShareLinkForm.jsx

@@ -5,6 +5,7 @@ import { withTranslation } from 'react-i18next';
 import dateFnsFormat from 'date-fns/format';
 import parse from 'date-fns/parse';
 
+import { isInteger } from 'core-js/fn/number';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 import { toastSuccess, toastError } from '../util/apiNotification';
@@ -18,10 +19,10 @@ class ShareLinkForm extends React.Component {
     super(props);
     this.state = {
       expirationType: 'unlimited',
-      numberOfDays: 7,
+      numberOfDays: '7',
       description: '',
       customExpirationDate: dateFnsFormat(new Date(), 'yyyy-MM-dd'),
-      customExpirationTime: dateFnsFormat(new Date(), 'hh:mm:s'),
+      customExpirationTime: dateFnsFormat(new Date(), 'hh:mm'),
     };
 
     this.handleChangeExpirationType = this.handleChangeExpirationType.bind(this);
@@ -40,7 +41,7 @@ class ShareLinkForm extends React.Component {
 
   /**
    * change numberOfDays
-   * @param {number} numberOfDays
+   * @param {string} numberOfDays
    */
   handleChangeNumberOfDays(numberOfDays) {
     this.setState({ numberOfDays });
@@ -74,6 +75,7 @@ class ShareLinkForm extends React.Component {
    * Generate expiredAt by expirationType
    */
   generateExpired() {
+    const { t } = this.props;
     const { expirationType } = this.state;
     let expiredAt;
 
@@ -82,14 +84,17 @@ class ShareLinkForm extends React.Component {
     }
 
     if (expirationType === 'numberOfDays') {
+      if (!isInteger(Number(this.state.numberOfDays))) {
+        throw new Error(t('share_links.Invalid_Number_of_Date'));
+      }
       const date = new Date();
-      date.setDate(date.getDate() + this.state.numberOfDays);
+      date.setDate(date.getDate() + Number(this.state.numberOfDays));
       expiredAt = date;
     }
 
     if (expirationType === 'custom') {
       const { customExpirationDate, customExpirationTime } = this.state;
-      expiredAt = parse(`${customExpirationDate}T${customExpirationTime}`, "yyyy-MM-dd'T'HH:mm:ss", new Date());
+      expiredAt = parse(`${customExpirationDate}T${customExpirationTime}`, "yyyy-MM-dd'T'HH:mm", new Date());
     }
 
     return expiredAt;
@@ -133,92 +138,101 @@ class ShareLinkForm extends React.Component {
 
   renderExpirationTypeOptions() {
     const { expirationType } = this.state;
+    const { t } = this.props;
 
     return (
-      <div className="form-group">
-        <div className="custom-control custom-radio offset-4 mb-2">
-          <input
-            type="radio"
-            className="custom-control-input"
-            id="customRadio1"
-            name="expirationType"
-            value="customRadio1"
-            checked={expirationType === 'unlimited'}
-            onChange={() => { this.handleChangeExpirationType('unlimited') }}
-          />
-          <label className="custom-control-label" htmlFor="customRadio1">Unlimited</label>
-        </div>
-
-        <div className="custom-control custom-radio offset-4 mb-2">
-          <input
-            type="radio"
-            className="custom-control-input"
-            id="customRadio2"
-            value="customRadio2"
-            checked={expirationType === 'numberOfDays'}
-            onChange={() => { this.handleChangeExpirationType('numberOfDays') }}
-            name="expirationType"
-          />
-          <label className="custom-control-label" htmlFor="customRadio2">
-            <div className="row align-items-center m-0">
+      <div className="form-group row">
+        <label htmlFor="inputDesc" className="col-md-5 col-form-label">{t('share_links.expire')}</label>
+        <div className="col-md-7">
+
+
+          <div className="custom-control custom-radio form-group ">
+            <input
+              type="radio"
+              className="custom-control-input"
+              id="customRadio1"
+              name="expirationType"
+              value="customRadio1"
+              checked={expirationType === 'unlimited'}
+              onChange={() => { this.handleChangeExpirationType('unlimited') }}
+            />
+            <label className="custom-control-label" htmlFor="customRadio1">{t('share_links.Unlimited')}</label>
+          </div>
+
+          <div className="custom-control custom-radio  form-group">
+            <input
+              type="radio"
+              className="custom-control-input"
+              id="customRadio2"
+              value="customRadio2"
+              checked={expirationType === 'numberOfDays'}
+              onChange={() => { this.handleChangeExpirationType('numberOfDays') }}
+              name="expirationType"
+            />
+            <label className="custom-control-label" htmlFor="customRadio2">
+              <div className="row align-items-center m-0">
+                <input
+                  type="number"
+                  min="1"
+                  className="col-4"
+                  name="expirationType"
+                  value={this.state.numberOfDays}
+                  onFocus={() => { this.handleChangeExpirationType('numberOfDays') }}
+                  onChange={e => this.handleChangeNumberOfDays(Number(e.target.value))}
+                />
+                <span className="col-auto">{t('share_links.Days')}</span>
+              </div>
+            </label>
+          </div>
+
+          <div className="custom-control custom-radio form-group text-nowrap mb-0">
+            <input
+              type="radio"
+              className="custom-control-input"
+              id="customRadio3"
+              name="expirationType"
+              value="customRadio3"
+              checked={expirationType === 'custom'}
+              onChange={() => { this.handleChangeExpirationType('custom') }}
+            />
+            <label className="custom-control-label" htmlFor="customRadio3">
+              {t('share_links.Custom')}
+            </label>
+            <div className="d-inline-flex flex-wrap">
+              <input
+                type="date"
+                className="ml-3 mb-2"
+                name="customExpirationDate"
+                value={this.state.customExpirationDate}
+                onFocus={() => { this.handleChangeExpirationType('custom') }}
+                onChange={e => this.handleChangeCustomExpirationDate(e.target.value)}
+              />
               <input
-                type="number"
-                min="1"
-                className="col-4"
-                name="expirationType"
-                value={this.state.numberOfDays}
-                onFocus={() => { this.handleChangeExpirationType('numberOfDays') }}
-                onChange={e => this.handleChangeNumberOfDays(Number(e.target.value))}
+                type="time"
+                className="ml-3 mb-2"
+                name="customExpiration"
+                value={this.state.customExpirationTime}
+                onFocus={() => { this.handleChangeExpirationType('custom') }}
+                onChange={e => this.handleChangeCustomExpirationTime(e.target.value)}
               />
-              <span className="col-auto">Days</span>
             </div>
-          </label>
-        </div>
-
-        <div className="custom-control custom-radio offset-4 mb-2">
-          <input
-            type="radio"
-            className="custom-control-input"
-            id="customRadio3"
-            name="expirationType"
-            value="customRadio3"
-            checked={expirationType === 'custom'}
-            onChange={() => { this.handleChangeExpirationType('custom') }}
-          />
-          <label className="custom-control-label" htmlFor="customRadio3">
-            Custom
-          </label>
-          <input
-            type="date"
-            className="ml-3"
-            name="customExpirationDate"
-            value={this.state.customExpirationDate}
-            onFocus={() => { this.handleChangeExpirationType('custom') }}
-            onChange={e => this.handleChangeCustomExpirationDate(e.target.value)}
-          />
-          <input
-            type="time"
-            className="ml-3"
-            name="customExpiration"
-            value={this.state.customExpirationTime}
-            onFocus={() => { this.handleChangeExpirationType('custom') }}
-            onChange={e => this.handleChangeCustomExpirationTime(e.target.value)}
-          />
+          </div>
         </div>
       </div>
     );
   }
 
   renderDescriptionForm() {
+    const { t } = this.props;
     return (
       <div className="form-group row">
-        <label htmlFor="inputDesc" className="col-md-4 col-form-label">Description</label>
+        <label htmlFor="inputDesc" className="col-md-5 col-form-label">{t('share_links.description')}</label>
         <div className="col-md-4">
           <input
             type="text"
             className="form-control"
             id="inputDesc"
-            placeholder="Enter description"
+            placeholder={t('share_links.enter_desc')}
             value={this.state.description}
             onChange={e => this.handleChangeDescription(e.target.value)}
           />
@@ -228,15 +242,15 @@ class ShareLinkForm extends React.Component {
   }
 
   render() {
+    const { t } = this.props;
     return (
-      <div className="share-link-form border p-3">
-        <h4>Expiration Date</h4>
-        {this.renderExpirationTypeOptions()}
-        <hr />
-        {this.renderDescriptionForm()}
-        <div className="text-right">
-          <button type="button" className="btn btn-primary" onClick={this.handleIssueShareLink}>
-            Issue
+      <div className="share-link-form p-3">
+        <h3 className="grw-modal-head pb-2"> { t('share_links.share_settings') }</h3>
+        <div className=" p-3">
+          {this.renderExpirationTypeOptions()}
+          {this.renderDescriptionForm()}
+          <button type="button" className="btn btn-primary d-block mx-auto px-5" onClick={this.handleIssueShareLink}>
+            {t('share_links.Issue')}
           </button>
         </div>
       </div>

+ 16 - 6
src/client/js/components/ShareLinkList.jsx

@@ -8,9 +8,11 @@ import dateFnsFormat from 'date-fns/format';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 import AppContainer from '../services/AppContainer';
+import CopyDropdown from './Page/CopyDropdown';
 
 const ShareLinkList = (props) => {
 
+  const { t } = props;
   function deleteLinkHandler(shareLinkId) {
     if (props.onClickDeleteButton == null) {
       return;
@@ -23,12 +25,18 @@ const ShareLinkList = (props) => {
       <>
         {props.shareLinks.map(shareLink => (
           <tr key={shareLink._id}>
-            <td>{shareLink._id}</td>
+            <td>
+              <div className="d-flex">
+                <span className="mr-auto my-auto">{shareLink._id}</span>
+                <CopyDropdown isShareLinkMode pagePath={shareLink.relatedPage.path} pageId={shareLink._id} />
+              </div>
+            </td>
+            {props.isAdmin && <td><a href={shareLink.relatedPage.path}>{shareLink.relatedPage.path}</a></td>}
             <td>{shareLink.expiredAt && <span>{dateFnsFormat(new Date(shareLink.expiredAt), 'yyyy-MM-dd HH:mm')}</span>}</td>
             <td>{shareLink.description}</td>
             <td>
               <button className="btn btn-outline-warning" type="button" onClick={() => deleteLinkHandler(shareLink._id)}>
-                <i className="icon-trash"></i>Delete
+                <i className="icon-trash"></i>{t('Delete')}
               </button>
             </td>
           </tr>
@@ -42,10 +50,11 @@ const ShareLinkList = (props) => {
       <table className="table table-bordered">
         <thead>
           <tr>
-            <th>Link</th>
-            <th>Expiration</th>
-            <th>Description</th>
-            <th>Order</th>
+            <th>{t('share_links.Share Link')}</th>
+            {props.isAdmin && <th>{t('share_links.Page Path')}</th>}
+            <th>{t('share_links.expire')}</th>
+            <th>{t('share_links.description')}</th>
+            <th></th>
           </tr>
         </thead>
         <tbody>
@@ -64,6 +73,7 @@ ShareLinkList.propTypes = {
 
   shareLinks: PropTypes.array.isRequired,
   onClickDeleteButton: PropTypes.func,
+  isAdmin: PropTypes.bool,
 };
 
 export default withTranslation()(ShareLinkListWrapper);

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

@@ -159,7 +159,9 @@ class Sidebar extends React.Component {
           calcViewHeightFunc={this.calcViewHeight}
         />
         <div id="grw-sidebar-content-container" className="grw-sidebar-content-container">
-          <SidebarContents />
+          <SidebarContents
+            isSharedUser={this.props.appContainer.isSharedUser}
+          />
         </div>
 
         <DrawerToggler iconClass="icon-arrow-left" />

+ 10 - 1
src/client/js/components/Sidebar/SidebarContents.jsx

@@ -10,8 +10,11 @@ import RecentChanges from './RecentChanges';
 import CustomSidebar from './CustomSidebar';
 
 const SidebarContents = (props) => {
+  const { navigationContainer, isSharedUser } = props;
 
-  const { navigationContainer } = props;
+  if (isSharedUser) {
+    return null;
+  }
 
   let Contents;
   switch (navigationContainer.state.sidebarContentsId) {
@@ -30,6 +33,12 @@ const SidebarContents = (props) => {
 
 SidebarContents.propTypes = {
   navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
+
+  isSharedUser: PropTypes.bool,
+};
+
+SidebarContents.defaultProps = {
+  isSharedUser: false,
 };
 
 /**

+ 147 - 0
src/client/js/models/Linker.js

@@ -0,0 +1,147 @@
+export default class Linker {
+
+  constructor(
+      type,
+      label,
+      link,
+      isUsePermanentLink = false,
+      permalink = '',
+  ) {
+    this.type = type;
+    this.label = label;
+    this.link = link;
+    this.isUsePermanentLink = isUsePermanentLink;
+    this.permalink = permalink;
+
+    this.generateMarkdownText = this.generateMarkdownText.bind(this);
+  }
+
+  static types = {
+    markdownLink: 'mdLink',
+    growiLink: 'growiLink',
+    pukiwikiLink: 'pukiwikiLink',
+  }
+
+  static patterns = {
+    pukiwikiLinkWithLabel: /^\[\[(?<label>.+)>(?<link>.+)\]\]$/, // https://regex101.com/r/2fNmUN/2
+    pukiwikiLinkWithoutLabel: /^\[\[(?<label>.+)\]\]$/, // https://regex101.com/r/S7w5Xu/1
+    growiLink: /^\[(?<label>\/.+)\]$/, // https://regex101.com/r/DJfkYf/3
+    markdownLink: /^\[(?<label>.*)\]\((?<link>.*)\)$/, // https://regex101.com/r/DZCKP3/2
+  }
+
+  generateMarkdownText() {
+    let reshapedLink = this.link;
+
+    if (this.isUsePermanentLink && this.permalink != null) {
+      reshapedLink = this.permalink;
+    }
+
+    if (this.label === '') {
+      this.label = reshapedLink;
+    }
+
+    if (this.type === Linker.types.pukiwikiLink) {
+      if (this.label === reshapedLink) return `[[${reshapedLink}]]`;
+      return `[[${this.label}>${reshapedLink}]]`;
+    }
+    if (this.type === Linker.types.growiLink) {
+      return `[${reshapedLink}]`;
+    }
+    if (this.type === Linker.types.markdownLink) {
+      return `[${this.label}](${reshapedLink})`;
+    }
+  }
+
+  // create an instance of Linker from string
+  static fromMarkdownString(str) {
+    // if str doesn't mean a linker, create a link whose label is str
+    let label = str;
+    let link = '';
+    let type = this.types.markdownLink;
+
+    // pukiwiki with separator ">".
+    if (str.match(this.patterns.pukiwikiLinkWithLabel)) {
+      type = this.types.pukiwikiLink;
+      ({ label, link } = str.match(this.patterns.pukiwikiLinkWithLabel).groups);
+    }
+    // pukiwiki without separator ">".
+    else if (str.match(this.patterns.pukiwikiLinkWithoutLabel)) {
+      type = this.types.pukiwikiLink;
+      ({ label } = str.match(this.patterns.pukiwikiLinkWithoutLabel).groups);
+      link = label;
+    }
+    // markdown
+    else if (str.match(this.patterns.markdownLink)) {
+      type = this.types.markdownLink;
+      ({ label, link } = str.match(this.patterns.markdownLink).groups);
+    }
+    // growi
+    else if (str.match(this.patterns.growiLink)) {
+      type = this.types.growiLink;
+      ({ label } = str.match(this.patterns.growiLink).groups);
+      link = label;
+    }
+
+    const isUsePermanentLink = false;
+    const permalink = '';
+
+    return new Linker(
+      type,
+      label,
+      link,
+      isUsePermanentLink,
+      permalink,
+    );
+  }
+
+  // create an instance of Linker from text with index
+  static fromLineWithIndex(line, index) {
+    const { beginningOfLink, endOfLink } = this.getBeginningAndEndIndexOfLink(line, index);
+    // if index is in a link, extract it from line
+    let linkStr = '';
+    if (beginningOfLink >= 0 && endOfLink >= 0) {
+      linkStr = line.substring(beginningOfLink, endOfLink);
+    }
+    return this.fromMarkdownString(linkStr);
+  }
+
+  // return beginning and end indexies of link
+  // if index is not in a link, return { beginningOfLink: -1, endOfLink: -1 }
+  static getBeginningAndEndIndexOfLink(line, index) {
+    let beginningOfLink;
+    let endOfLink;
+
+    // pukiwiki link ('[[link]]')
+    [beginningOfLink, endOfLink] = this.getBeginningAndEndIndexWithPrefixAndSuffix(line, index, '[[', ']]');
+
+    // markdown link ('[label](link)')
+    if (beginningOfLink < 0 || endOfLink < 0 || beginningOfLink > index || endOfLink < index) {
+      [beginningOfLink, endOfLink] = this.getBeginningAndEndIndexWithPrefixAndSuffix(line, index, '[', ')', '](');
+    }
+
+    // growi link ('[/link]')
+    if (beginningOfLink < 0 || endOfLink < 0 || beginningOfLink > index || endOfLink < index) {
+      [beginningOfLink, endOfLink] = this.getBeginningAndEndIndexWithPrefixAndSuffix(line, index, '[/', ']');
+    }
+
+    // return { beginningOfLink: -1, endOfLink: -1 }
+    if (beginningOfLink < 0 || endOfLink < 0 || beginningOfLink > index || endOfLink < index) {
+      [beginningOfLink, endOfLink] = [-1, -1];
+    }
+
+    return { beginningOfLink, endOfLink };
+  }
+
+  // return begin and end indexies as array only when index is between prefix and suffix and link contains containText.
+  static getBeginningAndEndIndexWithPrefixAndSuffix(line, index, prefix, suffix, containText = '') {
+    const beginningIndex = line.lastIndexOf(prefix, index);
+    const IndexOfContainText = line.indexOf(containText, beginningIndex + prefix.length);
+    const endIndex = line.indexOf(suffix, IndexOfContainText + containText.length);
+
+    if (beginningIndex < 0 || IndexOfContainText < 0 || endIndex < 0) {
+      return [-1, -1];
+    }
+    return [beginningIndex, endIndex + suffix.length];
+  }
+
+}

+ 16 - 1
src/client/js/services/AdminAppContainer.js

@@ -16,10 +16,12 @@ export default class AdminAppContainer extends Container {
     super();
 
     this.appContainer = appContainer;
+    this.dummyTitle = 0;
 
     this.state = {
       retrieveError: null,
-      title: '',
+      // set dummy value tile for using suspense
+      title: this.dummyTitle,
       confidential: '',
       globalLang: '',
       fileUpload: '',
@@ -265,6 +267,19 @@ export default class AdminAppContainer extends Container {
     return mailSettingParams;
   }
 
+  /**
+   * Initialize mail setting
+   * @memberOf AdminAppContainer
+   * @return {Array} Appearance
+   */
+  async initializeMailSettingHandler() {
+    const response = await this.appContainer.apiv3.delete('/app-settings/mail-setting', {});
+    const {
+      mailSettingParams,
+    } = response.data;
+    this.setState(mailSettingParams);
+  }
+
   /**
    * Update AWS setting
    * @memberOf AdminAppContainer

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

@@ -65,6 +65,7 @@ export default class PageContainer extends Container {
       hasChildren: JSON.parse(mainContent.getAttribute('data-page-has-children')),
       templateTagData: mainContent.getAttribute('data-template-tags') || null,
       shareLinksNumber:  mainContent.getAttribute('data-share-links-number'),
+      shareLinkId: JSON.parse(mainContent.getAttribute('data-share-link-id') || null),
 
       // latest(on remote) information
       remoteRevisionId: revisionId,

+ 3 - 3
src/client/styles/scss/_admin.scss

@@ -28,7 +28,7 @@
 
     .ss-container img {
       padding: 0.5em;
-      background-color: #ddd;
+      background-color: $gray-300;
     }
 
     .table-user-list {
@@ -140,8 +140,8 @@
 
     // style
     .theme-option-container a {
-      background-color: #f5f5f5;
-      border: 1px solid #ccc;
+      background-color: $gray-50;
+      border: 1px solid $gray-300;
     }
     .theme-option-name {
       opacity: 0.3;

+ 1 - 1
src/client/styles/scss/_comment.scss

@@ -38,7 +38,7 @@
       justify-content: flex-end;
 
       font-size: 0.9em;
-      color: #999;
+      color: $gray-400;
     }
   }
 

+ 2 - 2
src/client/styles/scss/_comment_kibela.scss

@@ -14,7 +14,7 @@
       height: 0;
       content: '';
       border-top: 20px solid transparent;
-      border-right: 20px solid #e6e9ec;
+      border-right: 20px solid $gray-200;
       border-bottom: 20px solid transparent;
       border-left: 20px solid transparent;
       border-left-width: 0;
@@ -65,7 +65,7 @@
     .page-comment-main {
       @extend %comment-section;
       margin-left: 4.5em;
-      background: #e6e9ec;
+      background: $gray-200;
       border-radius: 0.35em;
     }
 

+ 4 - 4
src/client/styles/scss/_editor-attachment.scss

@@ -22,7 +22,7 @@
         background: rgba(200, 200, 200, 0.8);
 
         .overlay-content {
-          color: #444;
+          color: $gray-700;
         }
       }
     }
@@ -51,7 +51,7 @@
       // accepted
       &.dropzone-accepted:not(.dropzone-rejected) {
         .overlay.overlay-dropzone-active {
-          border: 4px dashed #ccc;
+          border: 4px dashed $gray-300;
 
           .overlay-content {
             // insert content
@@ -62,7 +62,7 @@
             }
 
             // style
-            color: #666;
+            color: $secondary;
             background: rgba(200, 200, 200, 0.8);
           }
         }
@@ -106,7 +106,7 @@
     padding-bottom: 3px;
     font-size: small;
     border: none;
-    border-top: 1px dotted #ccc;
+    border-top: 1px dotted $gray-300;
     border-bottom: none;
 
     &:active {

+ 1 - 1
src/client/styles/scss/_editor-overlay.scss

@@ -4,7 +4,7 @@
     .overlay-content {
       padding: $contentPadding;
       font-size: $contentFontSize;
-      color: #444;
+      color: $gray-700;
       background: rgba(200, 200, 200, 0.5);
     }
   }

+ 4 - 4
src/client/styles/scss/_hljs.scss

@@ -15,8 +15,8 @@ pre.hljs {
     padding: 0 4px;
     font-style: normal;
     font-weight: bold;
-    color: #333;
-    background: #ccc;
+    color: $gray-900;
+    background: $gray-300;
     opacity: 0.6;
   }
 }
@@ -24,12 +24,12 @@ pre.hljs {
 // styles for highlightjs-line-numbers
 .hljs-ln td.hljs-ln-numbers {
   padding-right: 5px;
-  color: #ccc;
+  color: $gray-300;
 
   text-align: center;
   vertical-align: top;
   user-select: none;
-  border-right: 1px solid #ccc;
+  border-right: 1px solid $gray-300;
 }
 
 .hljs-ln td.hljs-ln-code {

+ 5 - 5
src/client/styles/scss/_layout.scss

@@ -94,10 +94,10 @@ body {
   }
   .main {
     header {
-      border-bottom: solid 1px #666;
+      border-bottom: solid 1px $secondary;
       h1 {
         font-size: 2em;
-        color: #000;
+        color: black;
       }
     }
 
@@ -110,7 +110,7 @@ body {
       max-width: 100%;
       margin-bottom: 20px;
       font-size: 0.9em;
-      border: solid 1px #aaa;
+      border: solid 1px $gray-400;
 
       .revision-toc-head {
         display: inline-block;
@@ -125,8 +125,8 @@ body {
 
     .meta {
       margin-top: 32px;
-      color: #666;
-      border-top: solid 1px #ccc;
+      color: $secondary;
+      border-top: solid 1px $gray-300;
     }
   }
 }

+ 8 - 0
src/client/styles/scss/_linkedit-preview.scss

@@ -0,0 +1,8 @@
+.modal .modal-body .linkedit-preview {
+  height: 0;
+  padding-bottom: 50%;
+
+  .page-editor-preview-body {
+    overflow-y: unset;
+  }
+}

+ 7 - 7
src/client/styles/scss/_login.scss

@@ -101,31 +101,31 @@
     ),
     'google': (
       rgba(#24292e, 0.4),
-      #444,
+      $gray-700,
     ),
     'github': (
       rgba(lighten(black, 20%), 0.4),
-      #444,
+      $gray-700,
     ),
     'facebook': (
       rgba(#29487d, 0.4),
-      #444,
+      $gray-700,
     ),
     'twitter': (
       rgba(#1da1f2, 0.4),
-      #444,
+      $gray-700,
     ),
     'oidc': (
       rgba(#24292e, 0.4),
-      #444,
+      $gray-700,
     ),
     'saml': (
       rgba(#55a79a, 0.4),
-      #444,
+      $gray-700,
     ),
     'basic': (
       rgba(#24292e, 0.4),
-      #444,
+      $gray-700,
     ),
   );
 

+ 1 - 1
src/client/styles/scss/_navbar_kibela.scss

@@ -4,7 +4,7 @@
   .grw-navbar {
     height: 60px;
     background: white;
-    border-bottom: solid 1px #e6e9ec;
+    border-bottom: solid 1px $gray-200;
     .navbar-nav {
       .confidential {
         color: white;

+ 28 - 14
src/client/styles/scss/_on-edit.scss

@@ -187,19 +187,33 @@ body.on-edit {
       }
 
       // add icon on cursor
+      .markdown-table-activated,
+      .markdown-link-activated {
+        .CodeMirror-cursor {
+          &:after {
+            position: relative;
+            top: -1.1em;
+            left: 0.3em;
+            display: block;
+            width: 1em;
+            height: 1em;
+            content: ' ';
+
+            background-repeat: no-repeat;
+            background-size: 1em;
+          }
+        }
+      }
+
       .markdown-table-activated .CodeMirror-cursor {
         &:after {
-          position: relative;
-          top: -1.1em;
-          left: 0.3em;
-          display: block;
-          width: 1em;
-          height: 1em;
-          content: ' ';
-
           background-image: url(/images/icons/editor/table.svg);
-          background-repeat: no-repeat;
-          background-size: 1em;
+        }
+      }
+
+      .markdown-link-activated .CodeMirror-cursor {
+        &:after {
+          background-image: url(/images/icons/editor/link.svg);
         }
       }
 
@@ -297,12 +311,12 @@ body.on-edit {
 
 #tag-edit-button-tooltip {
   .tooltip-inner {
-    color: #000;
-    background-color: #fff;
-    border: 1px solid #ccc;
+    color: black;
+    background-color: white;
+    border: 1px solid $gray-300;
   }
 
   .tooltip-arrow {
-    border-bottom: 5px solid #ccc;
+    border-bottom: 5px solid $gray-300;
   }
 }

+ 1 - 1
src/client/styles/scss/_override-bootstrap-variables.scss

@@ -79,7 +79,7 @@ $alert-color-level: -10;
 //== Progress bar
 $progress-height: 4px;
 $progress-border-radius: $border-radius-sm;
-$progress-bg: #f0f0f0;
+$progress-bg: $gray-100;
 $progress-box-shadow: none;
 
 //== Code

+ 4 - 4
src/client/styles/scss/_page.scss

@@ -4,7 +4,7 @@
 .main-container {
   .url-line {
     font-size: 1rem;
-    color: #999;
+    color: $gray-400;
   }
 
   h1.title {
@@ -17,7 +17,7 @@
 
     // crowi layout only
     a.last-path {
-      color: #ccc;
+      color: $gray-300;
 
       &:hover {
         color: inherit;
@@ -65,7 +65,7 @@
 
       .revision-history-diff {
         padding-left: 40px;
-        color: #333;
+        color: $gray-900;
         table-layout: fixed;
       }
     }
@@ -171,7 +171,7 @@
   left: 5%;
   width: 90%;
   height: 90%;
-  background: #000;
+  background: black;
 
   iframe {
     width: 100%;

+ 3 - 3
src/client/styles/scss/_page_list.scss

@@ -54,7 +54,7 @@ body .page-list {
 .popular-page-high {
   font-size: 1.1em;
   font-weight: bold;
-  color: #e80000;
+  color: darken($red, 5%);
 }
 
 .popular-page-mid {
@@ -67,8 +67,8 @@ body .page-list {
 }
 
 .card-timeline {
-  border: 1px solid #ccc;
+  border: 1px solid $gray-300;
   > .card-header {
-    background-color: #ccc;
+    background-color: $gray-300;
   }
 }

+ 5 - 5
src/client/styles/scss/_search.scss

@@ -1,6 +1,6 @@
 .search-listpage-icon {
   font-size: 16px;
-  color: #999;
+  color: $gray-400;
 }
 
 .search-listpage-clear {
@@ -11,7 +11,7 @@
   height: 22px;
   padding: 8px;
   font-size: 0.6em;
-  color: #ccc;
+  color: $gray-300;
 }
 
 .search-typeahead {
@@ -26,7 +26,7 @@
     width: 24px;
     height: 24px;
     padding: 0;
-    color: #999;
+    color: $gray-400;
   }
 
   .rbt-menu {
@@ -48,7 +48,7 @@
 
       .page-list-meta {
         font-size: 0.9em;
-        color: #999;
+        color: $gray-400;
 
         > span {
           margin-right: 0.3rem;
@@ -213,7 +213,7 @@
       .wiki {
         padding: 16px;
         font-size: 13px;
-        border: solid 1px #ccc;
+        border: solid 1px $gray-300;
       }
     }
   }

+ 3 - 3
src/client/styles/scss/_shortcuts.scss

@@ -30,15 +30,15 @@
     margin: 0px 4px;
     /*Text Properties*/
     font: 18px/36px Helvetica, serif;
-    color: #666;
+    color: $secondary;
     text-align: center;
     text-transform: uppercase;
-    background: #fff;
+    background: white;
     border-radius: 4px;
     box-shadow: 0px 1px 3px 1px rgba(0, 0, 0, 0.5);
     /* SVG Properties*/
     polygon {
-      fill: #666;
+      fill: $secondary;
     }
 
     &.key-longer {

+ 1 - 0
src/client/styles/scss/_sidebar.scss

@@ -82,6 +82,7 @@
   }
 
   .grw-sidebar-nav {
+    min-width: 62px;
     height: 100vh;
 
     .btn {

+ 1 - 1
src/client/styles/scss/_subnav.scss

@@ -56,7 +56,7 @@
     .picture {
       width: 22px;
       height: 22px;
-      border: 1px solid #ccc;
+      border: 1px solid $gray-300;
 
       &.picture-xs {
         width: 14px;

+ 2 - 2
src/client/styles/scss/_user.scss

@@ -16,7 +16,7 @@ $easeInOutCubic: cubic-bezier(0.65, 0, 0.35, 1);
   .user-page-name {
     margin: 0;
     font-size: 2.5em;
-    color: #666;
+    color: $secondary;
   }
 
   .picture {
@@ -26,7 +26,7 @@ $easeInOutCubic: cubic-bezier(0.65, 0, 0.35, 1);
 
   div.user-page-meta {
     padding-left: 0;
-    color: #999;
+    color: $gray-400;
 
     .user-page-username {
       font-weight: bold;

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

@@ -67,6 +67,7 @@ div.body {
     margin: 0 0 30px 0;
     font-size: 0.9em;
     color: lighten($gray-800, 35%);
+    border-left: 0.3rem solid #ddd;
   }
 
   img {

+ 16 - 9
src/client/styles/scss/atoms/_buttons.scss

@@ -1,14 +1,21 @@
-.btn.btn-outline-info.btn-like,
-.btn.btn-outline-warning.btn-bookmark {
-  color: $secondary;
-
-  &.active,
-  &:hover {
-    // header buttons are always white for active
-    color: white !important;
+.btn.btn-like {
+  @include button-outline-variant($secondary, lighten($info, 15%), rgba(lighten($info, 10%), 0.5), rgba(lighten($info, 10%), 0.5));
+  &:not(:disabled):not(.disabled):active,
+  &:not(:disabled):not(.disabled).active {
+    color: lighten($info, 15%);
+  }
+  &:not(:disabled):not(.disabled):not(:hover) {
+    background-color: transparent;
   }
+}
 
-  &:not(:hover):not(.active) {
+.btn.btn-bookmark {
+  @include button-outline-variant($secondary, $warning, rgba(lighten($warning, 20%), 0.5), rgba(lighten($warning, 20%), 0.5));
+  &:not(:disabled):not(.disabled):active,
+  &:not(:disabled):not(.disabled).active {
+    color: $warning;
+  }
+  &:not(:disabled):not(.disabled):not(:hover) {
     background-color: transparent;
   }
 }

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

@@ -61,6 +61,7 @@
 @import 'waves';
 @import 'wiki';
 @import 'sharelink';
+@import 'linkedit-preview';
 
 /*
  * for Guest User Mode

+ 5 - 5
src/client/styles/scss/style-presentation.scss

@@ -94,14 +94,14 @@
           > td {
             padding: 1em;
             vertical-align: top;
-            border-top: 1px solid #999;
+            border-top: 1px solid $gray-400;
           }
         }
       }
       // Bottom align for column headings
       > thead > tr > th {
         vertical-align: bottom;
-        border-bottom: 2px solid #888;
+        border-bottom: 2px solid $gray-500;
       }
       // Remove top border from thead by default
       > caption + thead,
@@ -116,18 +116,18 @@
       }
       // Account for multiple tbody instances
       > tbody + tbody {
-        border-top: 2px solid #888;
+        border-top: 2px solid $gray-500;
       }
 
       // .table-bordered
-      border: 1px solid #999;
+      border: 1px solid $gray-400;
       > thead,
       > tbody,
       > tfoot {
         > tr {
           > th,
           > td {
-            border: 1px solid #999;
+            border: 1px solid $gray-400;
           }
         }
       }

+ 5 - 5
src/client/styles/scss/theme/_apply-colors-dark.scss

@@ -16,7 +16,7 @@ $color-tags: #949494 !default;
 $bgcolor-tags: $dark !default;
 
 // override bootstrap variables
-$border-color: #444;
+$border-color: $gray-700;
 $table-dark-color: $color-table;
 $table-dark-bg: $bgcolor-table;
 $table-dark-border-color: $border-color-table;
@@ -125,7 +125,7 @@ ul.pagination {
   .input-group {
     .input-group-text {
       color: darken(white, 30%);
-      background-color: rgba(#444, 0.7);
+      background-color: rgba($gray-700, 0.7);
     }
 
     .form-control {
@@ -141,10 +141,10 @@ ul.pagination {
 
   .btn-fill {
     .btn-label {
-      color: #ccc;
+      color: $gray-300;
     }
     .btn-label-text {
-      color: #aaa;
+      color: $gray-400;
     }
   }
 
@@ -180,7 +180,7 @@ ul.pagination {
  */
 .grw-drawer-toggler {
   @extend .btn-dark;
-  color: #999;
+  color: $gray-400;
 }
 
 /*

+ 2 - 2
src/client/styles/scss/theme/_apply-colors-kibela.scss

@@ -60,7 +60,7 @@ body.kibela {
         background-color: transparent;
 
         &:hover {
-          background: #eee;
+          background: $gray-100;
         }
       }
 
@@ -124,7 +124,7 @@ body.kibela {
 
     /* Modal */
     .modal-title {
-      color: #ffffff; // override header colors
+      color: white; // override header colors
     }
     .modal-content {
       background-color: $themelight;

+ 7 - 7
src/client/styles/scss/theme/antarctic.scss

@@ -49,8 +49,8 @@ html[dark] {
 
   // Background colors
   $bgcolor-global: $themelight;
-  $bgcolor-inline-code: #f0f0f0; //optional
-  $bgcolor-card: #f5f5f5;
+  $bgcolor-inline-code: $gray-100; //optional
+  $bgcolor-card: $gray-50;
 
   // Font colors
   $color-global: black;
@@ -81,7 +81,7 @@ html[dark] {
 
   // Sidebar
   $bgcolor-sidebar: $themecolor;
-  $bgcolor-sidebar-nav-item-active: rgba(#000000, 0.37); // optional
+  $bgcolor-sidebar-nav-item-active: rgba(black, 0.37); // optional
   $text-shadow-sidebar-nav-item-active: 0px 0px 10px #0099ff; // optional
   // Sidebar resize button
   $color-resize-button: $color-reversal;
@@ -98,7 +98,7 @@ html[dark] {
   $color-editor-icons: $color-global;
 
   // Border colors
-  $border-color-theme: #ccc; // former: `$navbar-border: #ccc;`
+  $border-color-theme: $gray-300; // former: `$navbar-border: $gray-300;`
   $bordercolor-inline-code: #ccc8c8; // optional
 
   // Dropdown colors
@@ -151,7 +151,7 @@ html[dark] {
 
 //   // Font colors
 //   $color-global: #eeeeee;
-//   $color-reversal: #333333;
+//   $color-reversal: $gray-900;
 //   // $color-header: desaturate($primary, 20%);
 //   $color-link: $primary;
 //   $color-link-hover: lighten($color-link, 10%);
@@ -169,13 +169,13 @@ html[dark] {
 
 //   // Logo colors
 //   $bgcolor-logo: $bgcolor-navbar;
-//   $fillcolor-logo-mark: #444;
+//   $fillcolor-logo-mark: $gray-700;
 
 //   // Icon colors
 //   $color-editor-icons: darken($accentcolor, 15%);
 
 //   // Border colors
-//   $border-color-theme: black; // former: `$navbar-border: #ccc;`
+//   $border-color-theme: black; // former: `$navbar-border: $gray-300;`
 
 //   // Dropdown colors
 //   $bgcolor-dropdown-link-active: $primary;

+ 9 - 9
src/client/styles/scss/theme/christmas.scss

@@ -19,7 +19,7 @@ $subthemecolor: #30882c;
 $bgcolor-global: $themelight;
 $linktext: $subthemecolor;
 $linktext-hover: lighten($subthemecolor, 15%);
-$sidebar-text: #ffffff;
+$sidebar-text: white;
 $fillcolor-logo-mark: lighten(desaturate($themecolor, 50%), 50%);
 $color-link-wiki: lighten($subthemecolor, 5%);
 $color-link-wiki-hover: lighten($color-link-wiki, 15%);
@@ -41,19 +41,19 @@ html[light],
 html[dark] {
   $primary: #d3c665;
   // Background colors
-  $bgcolor-card: #f5f5f5;
-  $bgcolor-inline-code: #f0f0f0; //optional
+  $bgcolor-card: $gray-50;
+  $bgcolor-inline-code: $gray-100; //optional
 
   // Font colors
   $color-global: #112744;
-  $color-reversal: #eee;
+  $color-reversal: $gray-100;
   $color-link: $subthemecolor;
   $color-link-hover: lighten($color-link, 10%);
   $color-link-nabvar: $color-reversal;
   $color-inline-code: #c7254e; // optional
 
   // Table colors
-  $border-color-table: #aaa; // optional
+  $border-color-table: $gray-400; // optional
 
   // List Group colors
   // $color-list: $color-global;
@@ -74,7 +74,7 @@ html[dark] {
 
   // Sidebar
   $bgcolor-sidebar: $subthemecolor;
-  $bgcolor-sidebar-nav-item-active: rgba(#000000, 0.37); // optional
+  $bgcolor-sidebar-nav-item-active: rgba(black, 0.37); // optional
   $text-shadow-sidebar-nav-item-active: 0px 0px 10px $primary; // optional
   // Sidebar resize button
   $color-resize-button: $color-reversal;
@@ -90,7 +90,7 @@ html[dark] {
   $color-editor-icons: $color-global;
 
   // Border colors
-  $border-color-theme: #ccc; // former: `$navbar-border: #ccc;`
+  $border-color-theme: $gray-300; // former: `$navbar-border: $gray-300;`
   $bordercolor-inline-code: #ccc8c8; // optional
 
   // Dropdown colors
@@ -136,11 +136,11 @@ html[dark] {
   .nologin {
     .input-group {
       .input-group-text {
-        color: #444;
+        color: $gray-700;
         background-color: rgba(darken(white, 20%), 0.6);
       }
       .form-control {
-        color: #444;
+        color: $gray-700;
         background-color: rgba(white, 0.6);
       }
     }

+ 5 - 5
src/client/styles/scss/theme/default.scss

@@ -117,7 +117,7 @@ html[dark] {
 
   // Font colors
   $color-global: #a8a8a8;
-  $color-reversal: #333333;
+  $color-reversal: $gray-900;
   // $color-header: desaturate($primary, 20%);
   $color-link: #7b9ad5;
   $color-link-hover: lighten($color-link, 10%);
@@ -148,7 +148,7 @@ html[dark] {
 
   // Logo colors
   $bgcolor-logo: $bgcolor-navbar;
-  $fillcolor-logo-mark: #444;
+  $fillcolor-logo-mark: $gray-700;
 
   // Sidebar
   $bgcolor-sidebar: #122c55;
@@ -169,7 +169,7 @@ html[dark] {
   $bgcolor-subnav: lighten($bgcolor-global, 4%); // optional
 
   // Tabs
-  $bordercolor-nav-tabs: #444; // optional
+  $bordercolor-nav-tabs: $gray-700; // optional
   // $color-nav-tabs-link-active: #; //optional
   $bordercolor-nav-tabs-hover: #666 #666 $bordercolor-nav-tabs; // optional
   // $bordercolor-nav-tabs-active: # # $bgcolor-global; // optional
@@ -182,8 +182,8 @@ html[dark] {
   $color-editor-icons: $color-global;
 
   // Border colors
-  $border-color-theme: #444;
-  $bordercolor-inline-code: #666; // optional
+  $border-color-theme: $gray-700;
+  $bordercolor-inline-code: $secondary; // optional
 
   // Dropdown colors
   $bgcolor-dropdown-link-active: $primary;

+ 1 - 1
src/client/styles/scss/theme/future.scss

@@ -14,7 +14,7 @@ html[dark] {
 
   // Font colors
   $color-global: #95abba;
-  $color-reversal: #222;
+  $color-reversal: $gray-900;
   $color-header: #95abba;
   $color-link: $accentcolor;
   $color-link-hover: lighten($color-link, 20%);

+ 3 - 3
src/client/styles/scss/theme/halloween.scss

@@ -38,7 +38,7 @@ html[dark] {
   // Background colors
   $bgcolor-global: #050000;
   $bgcolor-inline-code: #1f1f22; //optional
-  $bgcolor-card: #f5f5f5;
+  $bgcolor-card: $gray-50;
 
   // Font colors
   $color-global: #e9af2b;
@@ -93,7 +93,7 @@ html[dark] {
   $color-editor-icons: $color-global;
 
   // Border colors
-  $border-color-theme: #ccc; // former: `$navbar-border: #ccc;`
+  $border-color-theme: $gray-300; // former: `$navbar-border: $gray-300;`
   $bordercolor-inline-code: #4d4d4d; // optional
 
   // Dropdown colors
@@ -114,6 +114,6 @@ html[dark] {
 
   pre {
     color: #edba4a;
-    background: #000000;
+    background: black;
   }
 }

+ 5 - 5
src/client/styles/scss/theme/island.scss

@@ -8,9 +8,9 @@ html[light],
 html[dark] {
   $primary: $color-primary;
   // Background colors
-  $bgcolor-card: #f5f5f5;
+  $bgcolor-card: $gray-50;
   $bgcolor-global: lighten($color-themelight, 10%);
-  $bgcolor-inline-code: #f0f0f0; //optional
+  $bgcolor-inline-code: $gray-100; //optional
 
   // Font colors
   $color-global: #112744;
@@ -49,7 +49,7 @@ html[dark] {
 
   // Sidebar
   $bgcolor-sidebar: #0d3955;
-  $bgcolor-sidebar-nav-item-active: rgba(#000000, 0.37);
+  $bgcolor-sidebar-nav-item-active: rgba(black, 0.37);
   // $bgcolor-sidebar-nav-item-active: rgba(#969494, 0.3); // optional
   $text-shadow-sidebar-nav-item-active: 0px 0px 10px #0099ff; // optional
   // Sidebar resize button
@@ -64,13 +64,13 @@ html[dark] {
   $bgcolor-sidebar-list-group: #eff8f7; // optional
 
   // Tabs
-  $bordercolor-nav-tabs: #ccc; // optional
+  $bordercolor-nav-tabs: $gray-300; // optional
 
   // Icon colors
   $color-editor-icons: $color-global;
 
   // Border colors
-  $border-color-theme: #ccc;
+  $border-color-theme: $gray-300;
   $bordercolor-inline-code: #ccc8c8; // optional
 
   // Dropdown colors

+ 1 - 1
src/client/styles/scss/theme/kibela.scss

@@ -20,7 +20,7 @@ html[dark] {
   $color-link: rgb(74, 109, 204);
   $color-link-hover: lighten($color-link, 12%);
   $sidebar-text: $bgcolor-theme;
-  $color-reversal: #eee;
+  $color-reversal: $gray-100;
 
   $primary: $bgcolor-theme;
   $info: lighten($bgcolor-theme, 20%);

+ 4 - 4
src/client/styles/scss/theme/mono-blue.scss

@@ -12,12 +12,12 @@ html[light] {
 
   // Background colors
   $bgcolor-global: $themelight;
-  $bgcolor-inline-code: #f0f0f0; //optional
+  $bgcolor-inline-code: $gray-100; //optional
   $bgcolor-card: darken($themelight, 5%);
 
   // Font colors
   $color-global: $themecolor;
-  $color-reversal: #eee;
+  $color-reversal: $gray-100;
   $color-link: lighten($primary, 5%);
   $color-link-hover: lighten($color-link, 12%);
   $color-link-wiki: lighten($primary, 20%);
@@ -62,7 +62,7 @@ html[light] {
   $color-editor-icons: $color-global;
 
   // Border colors
-  $border-color-theme: #ccc;
+  $border-color-theme: $gray-300;
   $bordercolor-inline-code: #ccc8c8; // optional
 
   // Dropdown colors
@@ -108,7 +108,7 @@ html[dark] {
 
   // Font colors
   $color-global: #d3d4d4;
-  $color-reversal: #eee;
+  $color-reversal: $gray-100;
   $color-link: #97d1f0;
   $color-link-hover: darken($color-link, 12%);
   $color-link-wiki: lighten($primary, 20%);

+ 4 - 4
src/client/styles/scss/theme/nature.scss

@@ -38,11 +38,11 @@ $themecolor: #12b105;
 html[light],
 html[dark] {
   $primary: #460039;
-  $light: #f0f0f0;
+  $light: $gray-100;
 
   // Background colors
   $bgcolor-global: #fdfdfd;
-  $bgcolor-inline-code: #f0f0f0; //optional
+  $bgcolor-inline-code: $gray-100; //optional
   $bgcolor-card: #f1ffe4;
   $bgcolor-subnav: #fafafa;
 
@@ -79,7 +79,7 @@ html[dark] {
   $color-editor-icons: $color-global;
 
   // Border colors
-  $border-color-theme: #ccc;
+  $border-color-theme: $gray-300;
   $bordercolor-inline-code: #ccc8c8; // optional
 
   // Dropdown colors
@@ -88,7 +88,7 @@ html[dark] {
   $color-dropdown-link-hover: $color-global;
 
   // Table colors
-  $border-color-table: #aaa; // optional
+  $border-color-table: $gray-400; // optional
 
   // admin theme box
   $color-theme-color-box: lighten($primary, 20%);

+ 3 - 3
src/client/styles/scss/theme/spring.scss

@@ -32,8 +32,8 @@ html[dark] {
 
   // Background colors
   $bgcolor-global: white;
-  $bgcolor-inline-code: #f0f0f0; //optional
-  $bgcolor-card: #f5f5f5;
+  $bgcolor-inline-code: $gray-100; //optional
+  $bgcolor-card: $gray-50;
 
   // Font colors
   $color-global: black;
@@ -79,7 +79,7 @@ html[dark] {
   $color-editor-icons: $color-global;
 
   // Border colors
-  $border-color-theme: #ccc; // former: `$navbar-border: #ccc;`
+  $border-color-theme: $gray-300; // former: `$navbar-border: $gray-300;`
   $bordercolor-inline-code: #ccc8c8; // optional
 
   // Dropdown colors

+ 4 - 4
src/client/styles/scss/theme/wood.scss

@@ -41,7 +41,7 @@ html[dark] {
   $primary: #aaa45f;
 
   // Background colors
-  $bgcolor-global: #ffffff;
+  $bgcolor-global: white;
   $bgcolor-card: #ece8de;
 
   // Font colors
@@ -63,7 +63,7 @@ html[dark] {
   // List Group colors
   // $color-list: $color-global;
   $bgcolor-list: transparent;
-  $color-list-hover: #eee;
+  $color-list-hover: $gray-100;
   $bgcolor-list-hover: darken($bgcolor-global, 3%);
   // $color-list-active: $color-reversal;
   // $bgcolor-list-active: $primary;
@@ -71,7 +71,7 @@ html[dark] {
   // Table colors
   // $color-table: #; // optional
   // $bgcolor-table: #; // optional
-  $border-color-table: #aaa; // optional
+  $border-color-table: $gray-400; // optional
   // $color-table-hover: #; // optional
   // $bgcolor-table-hover: #; // optional
 
@@ -97,7 +97,7 @@ html[dark] {
   $bgcolor-resize-button: $themecolor;
 
   // Border colors
-  $border-color-theme: #ccc; // former: `$navbar-border: #ccc;`
+  $border-color-theme: $gray-300; // former: `$navbar-border: $gray-300;`
   $bordercolor-inline-code: #ccc8c8; // optional
 
   // Dropdown colors

+ 5 - 5
src/linter-checker/test.scss

@@ -2,7 +2,7 @@
  * VSCode の Stylelint 設定チェック方法
  *
  * 1. .stylelintrc.json ファイル中の `src/linter-checker/test.scss` 行を削除
- * 
+ *
  * 2. VSCode で以下のエラーが表示されていることを確認
  *   - color で stylelint(order/properties-order)
  *   - ul で stylelint(selector-combinator-space-after)
@@ -16,10 +16,10 @@
  */
 
 .test {
-  background: #ccc;
-  color: #333;
+  background: $gray-300;
+  color: $gray-900;
 
-  ul>li {
+  ul > li {
     margin-left: 0;
   }
-}
+}

+ 7 - 1
src/server/middlewares/access-token-parser.js

@@ -16,6 +16,12 @@ module.exports = (crowi) => {
     logger.debug('accessToken is', accessToken);
 
     const user = await User.findUserByApiToken(accessToken);
+
+    if (user == null) {
+      logger.debug('The access token is invalid');
+      return next();
+    }
+
     // transforming attributes
     // see User model
     req.user = user.toObject();
@@ -23,7 +29,7 @@ module.exports = (crowi) => {
 
     logger.debug('Access token parsed: skipCsrfVerify');
 
-    next();
+    return next();
   };
 
 };

+ 46 - 0
src/server/middlewares/certify-shared-file.js

@@ -0,0 +1,46 @@
+const loggerFactory = require('@alias/logger');
+const url = require('url');
+
+const logger = loggerFactory('growi:middleware:certify-shared-fire');
+
+module.exports = (crowi) => {
+
+  return async(req, res, next) => {
+    const { referer } = req.headers;
+    const { path } = url.parse(referer);
+
+    if (!path.startsWith('/share/')) {
+      next();
+    }
+
+    const fileId = req.params.id || null;
+
+    const Attachment = crowi.model('Attachment');
+    const ShareLink = crowi.model('ShareLink');
+
+    const attachment = await Attachment.findOne({ _id: fileId });
+
+    if (attachment == null) {
+      next();
+    }
+
+    const shareLinks = await ShareLink.find({ relatedPage: attachment.page });
+
+    // If sharelinks don't exist, skip it
+    if (shareLinks.length === 0) {
+      next();
+    }
+
+    // Is there a valid share link
+    shareLinks.map((sharelink) => {
+      if (!sharelink.isExpired()) {
+        logger.debug('Confirmed target file belong to a share page');
+        req.isSharedPage = true;
+      }
+      return;
+    });
+
+    next();
+  };
+
+};

+ 31 - 0
src/server/middlewares/certify-shared-page.js

@@ -0,0 +1,31 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:middleware:certify-shared-page');
+
+module.exports = (crowi) => {
+
+  return async(req, res, next) => {
+    const pageId = req.query.page_id || req.body.page_id || null;
+    const shareLinkId = req.query.share_link_id || req.body.share_link_id || null;
+    if (pageId == null || shareLinkId == null) {
+      return next();
+    }
+
+    const ShareLink = crowi.model('ShareLink');
+    const sharelink = await ShareLink.findOne({ _id: shareLinkId, relatedPage: pageId });
+
+    // check sharelink enabled
+    if (sharelink == null || sharelink.isExpired()) {
+      return next();
+    }
+
+    logger.debug('shareLink id is', sharelink._id);
+
+    req.isSharedPage = true;
+
+    logger.debug('Confirmed target page id is a share page');
+
+    next();
+  };
+
+};

+ 6 - 0
src/server/middlewares/login-required.js

@@ -17,6 +17,12 @@ module.exports = (crowi, isGuestAllowed = false) => {
       return next();
     }
 
+    // check the page is shared
+    if (isGuestAllowed && req.isSharedPage) {
+      logger.debug('Target page is shared page');
+      return next();
+    }
+
     const User = crowi.model('User');
 
     // check the user logged in

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

@@ -1181,19 +1181,24 @@ module.exports = function(crowi) {
     const Attachment = crowi.model('Attachment');
     const Comment = crowi.model('Comment');
     const PageTagRelation = crowi.model('PageTagRelation');
+    const ShareLink = crowi.model('ShareLink');
     const Revision = crowi.model('Revision');
     const pageId = pageData._id;
     const socketClientId = options.socketClientId || null;
 
     debug('Completely delete', pageData.path);
 
-    await Bookmark.removeBookmarksByPageId(pageId);
-    await Attachment.removeAttachmentsByPageId(pageId);
-    await Comment.removeCommentsByPageId(pageId);
-    await PageTagRelation.remove({ relatedPage: pageId });
-    await Revision.removeRevisionsByPath(pageData.path);
-    await this.findByIdAndRemove(pageId);
-    await this.removeRedirectOriginPageByPath(pageData.path);
+    await Promise.all([
+      Bookmark.removeBookmarksByPageId(pageId),
+      Attachment.removeAttachmentsByPageId(pageId),
+      Comment.removeCommentsByPageId(pageId),
+      PageTagRelation.remove({ relatedPage: pageId }),
+      ShareLink.remove({ relatedPage: pageId }),
+      Revision.removeRevisionsByPath(pageData.path),
+      this.findByIdAndRemove(pageId),
+      this.removeRedirectOriginPageByPath(pageData.path),
+    ]);
+
     if (socketClientId != null) {
       pageEvent.emit('delete', pageData, user, socketClientId); // update as renamed page
     }

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

@@ -9,7 +9,7 @@ module.exports = function(crowi) {
 
   const ObjectId = mongoose.Schema.Types.ObjectId;
   const revisionSchema = new mongoose.Schema({
-    path: { type: String, required: true },
+    path: { type: String, required: true, index: true },
     body: {
       type: String,
       required: true,

+ 65 - 16
src/server/routes/apiv3/app-settings.js

@@ -320,6 +320,27 @@ module.exports = (crowi) => {
     await sendMailPromiseWrapper(smtpClient, mailOptions);
   }
 
+  const updateMailSettinConfig = async function(requestMailSettingParams) {
+    const {
+      configManager,
+      mailService,
+    } = crowi;
+
+    // update config without publishing S2sMessage
+    await configManager.updateConfigsInTheSameNamespace('crowi', requestMailSettingParams, true);
+
+    await mailService.initialize();
+    mailService.publishUpdatedMessage();
+
+    return {
+      fromAddress: configManager.getConfig('crowi', 'mail:from'),
+      smtpHost: configManager.getConfig('crowi', 'mail:smtpHost'),
+      smtpPort: configManager.getConfig('crowi', 'mail:smtpPort'),
+      smtpUser: configManager.getConfig('crowi', 'mail:smtpUser'),
+      smtpPassword: configManager.getConfig('crowi', 'mail:smtpPassword'),
+    };
+  };
+
   /**
    * @swagger
    *
@@ -327,7 +348,7 @@ module.exports = (crowi) => {
    *      put:
    *        tags: [AppSettings]
    *        operationId: updateAppSettingMailSetting
-   *        summary: /app-settings/site-url-setting
+   *        summary: /app-settings/mail-setting
    *        description: Update mail setting
    *        requestBody:
    *          required: true
@@ -364,21 +385,7 @@ module.exports = (crowi) => {
     };
 
     try {
-      const { configManager, mailService } = crowi;
-
-      // update config without publishing S2sMessage
-      await configManager.updateConfigsInTheSameNamespace('crowi', requestMailSettingParams, true);
-
-      await mailService.initialize();
-      mailService.publishUpdatedMessage();
-
-      const mailSettingParams = {
-        fromAddress: configManager.getConfig('crowi', 'mail:from'),
-        smtpHost: configManager.getConfig('crowi', 'mail:smtpHost'),
-        smtpPort: configManager.getConfig('crowi', 'mail:smtpPort'),
-        smtpUser: configManager.getConfig('crowi', 'mail:smtpUser'),
-        smtpPassword: configManager.getConfig('crowi', 'mail:smtpPassword'),
-      };
+      const mailSettingParams = await updateMailSettinConfig(requestMailSettingParams);
       return res.apiv3({ mailSettingParams });
     }
     catch (err) {
@@ -388,6 +395,48 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+   * @swagger
+   *
+   *    /app-settings/mail-setting:
+   *      delete:
+   *        tags: [AppSettings]
+   *        operationId: deleteAppSettingMailSetting
+   *        summary: /app-settings/mail-setting
+   *        description: delete mail setting
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/MailSettingParams'
+   *        responses:
+   *          200:
+   *            description: Succeeded to delete mail setting
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/MailSettingParams'
+   */
+  router.delete('/mail-setting', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+    const requestMailSettingParams = {
+      'mail:from': null,
+      'mail:smtpHost': null,
+      'mail:smtpPort': null,
+      'mail:smtpUser': null,
+      'mail:smtpPassword': null,
+    };
+    try {
+      const mailSettingParams = await updateMailSettinConfig(requestMailSettingParams);
+      return res.apiv3({ mailSettingParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in initializing mail setting';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'initialize-mailSetting-failed'));
+    }
+  });
+
   /**
    * @swagger
    *

+ 1 - 1
src/server/routes/apiv3/share-links.js

@@ -52,7 +52,7 @@ module.exports = (crowi) => {
   router.get('/', loginRequired, async(req, res) => {
     const { relatedPage } = req.query;
     try {
-      const shareLinksResult = await ShareLink.find({ relatedPage: { $in: relatedPage } });
+      const shareLinksResult = await ShareLink.find({ relatedPage: { $in: relatedPage } }).populate({ path: 'relatedPage', select: 'path' });
       return res.apiv3({ shareLinksResult });
     }
     catch (err) {

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

@@ -10,6 +10,8 @@ module.exports = function(crowi, app) {
   const loginRequiredStrictly = require('../middlewares/login-required')(crowi);
   const loginRequired = require('../middlewares/login-required')(crowi, true);
   const adminRequired = require('../middlewares/admin-required')(crowi);
+  const certifySharedPage = require('../middlewares/certify-shared-page')(crowi);
+  const certifySharedFile = require('../middlewares/certify-shared-file')(crowi);
   const csrf = require('../middlewares/csrf')(crowi);
 
   const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
@@ -120,7 +122,7 @@ module.exports = function(crowi, app) {
 
   app.get('/:id([0-9a-z]{24})'       , loginRequired , page.redirector);
   app.get('/_r/:id([0-9a-z]{24})'    , loginRequired , page.redirector); // alias
-  app.get('/attachment/:id([0-9a-z]{24})'  , loginRequired, attachment.api.get);
+  app.get('/attachment/:id([0-9a-z]{24})' , certifySharedFile , loginRequired, attachment.api.get);
   app.get('/attachment/profile/:id([0-9a-z]{24})' , loginRequired, attachment.api.get);
   app.get('/attachment/:pageId/:fileName', loginRequired, attachment.api.obsoletedGetForMongoDB); // DEPRECATED: remains for backward compatibility for v3.3.x or below
   app.get('/download/:id([0-9a-z]{24})'    , loginRequired, attachment.api.download);
@@ -164,9 +166,8 @@ module.exports = function(crowi, app) {
   app.post('/_api/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , csrf, attachment.api.removeProfileImage);
   app.get('/_api/attachments.limit'   , accessTokenParser , loginRequiredStrictly, attachment.api.limit);
 
-  app.get('/_api/revisions.get'       , accessTokenParser , loginRequired , revision.api.get);
-  app.get('/_api/revisions.ids'       , accessTokenParser , loginRequired , revision.api.ids);
-  app.get('/_api/revisions.list'      , accessTokenParser , loginRequired , revision.api.list);
+  app.get('/_api/revisions.get'       , certifySharedPage , accessTokenParser , loginRequired , revision.api.get);
+  app.get('/_api/revisions.ids'       , certifySharedPage , accessTokenParser , loginRequired , revision.api.ids);
 
   app.get('/trash$'                   , loginRequired , page.trashPageShowWrapper);
   app.get('/trash/$'                  , loginRequired , page.trashPageListShowWrapper);

+ 15 - 0
src/server/routes/page.js

@@ -318,6 +318,9 @@ module.exports = function(crowi, app) {
     addRendarVarsForPage(renderVars, portalPage);
     await addRenderVarsForSlack(renderVars, portalPage);
 
+    const sharelinksNumber = await ShareLink.countDocuments({ relatedPage: portalPage._id });
+    renderVars.sharelinksNumber = sharelinksNumber;
+
     const limit = 50;
     const offset = parseInt(req.query.offset) || 0;
 
@@ -412,6 +415,7 @@ module.exports = function(crowi, app) {
 
   actions.showSharedPage = async function(req, res, next) {
     const { linkId } = req.params;
+    const revisionId = req.query.revision;
 
     const layoutName = configManager.getConfig('crowi', 'customize:layout');
     const view = `layout-${layoutName}/shared_page`;
@@ -435,6 +439,17 @@ module.exports = function(crowi, app) {
 
     renderVars.sharelink = shareLink;
 
+    // presentation mode
+    if (req.query.presentation) {
+      page = await page.populateDataToMakePresentation(revisionId);
+
+      // populate
+      addRendarVarsForPage(renderVars, page);
+      return res.render('page_presentation', renderVars);
+    }
+
+    page.initLatestRevisionField(revisionId);
+
     // populate
     page = await page.populateDataToShowRevision();
     addRendarVarsForPage(renderVars, page);

+ 18 - 93
src/server/routes/revision.js

@@ -45,7 +45,6 @@
  */
 
 module.exports = function(crowi, app) {
-  const debug = require('debug')('growi:routes:revision');
   const logger = require('@alias/logger')('growi:routes:revision');
   const Page = crowi.model('Page');
   const Revision = crowi.model('Revision');
@@ -102,14 +101,14 @@ module.exports = function(crowi, app) {
   actions.api.get = async function(req, res) {
     const pageId = req.query.page_id;
     const revisionId = req.query.revision_id;
+    const { isSharedPage } = req;
 
     if (!pageId || !revisionId) {
       return res.json(ApiResponse.error('Parameter page_id and revision_id are required.'));
     }
 
     // check whether accessible
-    const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
-    if (!isAccessible) {
+    if (!isSharedPage && !(await Page.isAccessiblePageByViewer(pageId, req.user))) {
       return res.json(ApiResponse.error('Current user is not accessible to this page.'));
     }
 
@@ -163,101 +162,27 @@ module.exports = function(crowi, app) {
    *
    * @apiParam {String} page_id      Page Id.
    */
-  actions.api.ids = function(req, res) {
-    const pageId = req.query.page_id || null;
+  actions.api.ids = async function(req, res) {
+    const pageId = req.query.page_id;
+    const { isSharedPage } = req;
 
-    if (pageId && crowi.isPageId(pageId)) {
-      Page.findByIdAndViewer(pageId, req.user)
-        .then((pageData) => {
-          debug('Page found', pageData._id, pageData.path);
-          return Revision.findRevisionList(pageData.path);
-        })
-        .then((revisions) => {
-          return res.json(ApiResponse.success({ revisions }));
-        })
-        .catch((err) => {
-          return res.json(ApiResponse.error(err));
-        });
-    }
-    else {
-      return res.json(ApiResponse.error('Parameter error.'));
+    if (pageId == null) {
+      return res.json(ApiResponse.error('Parameter page_id is required.'));
     }
-  };
-
-  /**
-   * @swagger
-   *
-   *    /revisions.list:
-   *      get:
-   *        tags: [Revisions, CrowiCompatibles]
-   *        operationId: revisions.list
-   *        summary: /revisions.list
-   *        description: Get revisions
-   *        parameters:
-   *          - in: query
-   *            name: page_id
-   *            schema:
-   *              $ref: '#/components/schemas/Page/properties/_id'
-   *          - in: query
-   *            name: revision_ids
-   *            schema:
-   *              type: string
-   *              description: revision ids
-   *              example: 5e0734e472560e001761fa68,5e079a0a0afa6700170a75fb
-   *        responses:
-   *          200:
-   *            description: Succeeded to get revisions.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    revisions:
-   *                      type: array
-   *                      items:
-   *                        $ref: '#/components/schemas/Revision'
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {get} /revisions.list Get revisions
-   * @apiName ListRevision
-   * @apiGroup Revision
-   *
-   * @apiParam {String} revision_ids Revision Ids.
-   * @apiParam {String} page_id      Page Id.
-   */
-  actions.api.list = function(req, res) {
-    const revisionIds = (req.query.revision_ids || '').split(',');
-    const pageId = req.query.page_id || null;
 
-    if (pageId) {
-      Page.findByIdAndViewer(pageId, req.user)
-        .then((pageData) => {
-          debug('Page found', pageData._id, pageData.path);
-          return Revision.findRevisionList(pageData.path, {});
-        })
-        .then((revisions) => {
-          return res.json(ApiResponse.success(revisions));
-        })
-        .catch((err) => {
-          return res.json(ApiResponse.error(err));
-        });
+    // check whether accessible
+    if (!isSharedPage && !(await Page.isAccessiblePageByViewer(pageId, req.user))) {
+      return res.json(ApiResponse.error('Current user is not accessible to this page.'));
     }
-    else if (revisionIds.length > 0) {
-      Revision.findRevisions(revisionIds)
-        .then((revisions) => {
-          return res.json(ApiResponse.success(revisions));
-        })
-        .catch((err) => {
-          return res.json(ApiResponse.error(err));
-        });
+
+    try {
+      const page = await Page.findOne({ _id: pageId });
+      const revisions = await Revision.findRevisionIdList(page.path);
+      return res.json(ApiResponse.success({ revisions }));
     }
-    else {
-      return res.json(ApiResponse.error('Parameter error.'));
+    catch (err) {
+      logger.error('Error revisios.ids', err);
+      return res.json(ApiResponse.error(err));
     }
   };
 

+ 1 - 1
src/server/views/admin/customize.html

@@ -7,7 +7,7 @@
 {{ cdnStyleTag('jquery-ui') }}
 <style>
   .CodeMirror {
-    border: 1px solid #eee;
+    border: 1px solid $gray-100;
   }
 </style>
 {% endblock %}

+ 8 - 4
src/server/views/layout-growi/shared_page.html

@@ -2,7 +2,7 @@
 
 
 {% block content_header %}
-  <h1 class="p-3">{{ encodeURI(page.path) }}</h1>
+  <h1 class="p-3">{{ page.path | preventXss }}</h1>
 {% endblock %}
 
 
@@ -14,10 +14,15 @@
 {% endblock %}
 
 {% block content_main %}
-  <div class="row" id="is-shared-page" data-share-link-expired-at="{{ sharelink.expiredAt|datetz('Y/m/d H:i:s')}}" data-share-link-created-at="{{ sharelink.createdAt|datetz('Y/m/d H:i:s')}}">
+  <div
+    class="row"
+    id="is-shared-page"
+    data-share-link-expired-at="{% if sharelink.expiredAt %}{{ sharelink.expiredAt|datetz('Y/m/d H:i:s')}}{% endif %}"
+    data-share-link-created-at="{{ sharelink.createdAt|datetz('Y/m/d H:i:s')}}"
+  >
     {% block content_page %}
       <div class="col grw-page-content-container">
-      <div id="share-link-alert"></div>
+        <div id="share-link-alert"></div>
 
         {% include '../widget/page_content.html' %}
         {# force remove #revision-toc from #content_main of parent #}
@@ -36,7 +41,6 @@
     {% endblock %}
 
   </div>
-
 {% endblock %}
 
 

+ 2 - 2
src/server/views/layout-kibela/shared_page.html

@@ -2,7 +2,7 @@
 
 
 {% block content_header %}
-  <h1 class="p-3">{{ encodeURI(page.path) }}</h1>
+  <h1 class="p-3">{{ page.path | preventXss }}</h1>
 {% endblock %}
 
 
@@ -14,7 +14,7 @@
 {% endblock %}
 
 {% block content_main %}
-  <div class="row" id="is-shared-page" data-share-link-expired-at="{{ sharelink.expiredAt|datetz('Y/m/d H:i:s')}}" data-share-link-created-at="{{ sharelink.createdAt|datetz('Y/m/d H:i:s')}}">
+  <div class="row" id="is-shared-page" data-share-link-expired-at="{% if sharelink.expiredAt %}{{ sharelink.expiredAt|datetz('Y/m/d H:i:s')}}{% endif %}" data-share-link-created-at="{{ sharelink.createdAt|datetz('Y/m/d H:i:s')}}">
     {% block content_page %}
       <div class="col-12 col-xl-9 col-lg-8 bg-white round-corner">
         <div id="share-link-alert"></div>

+ 2 - 0
src/server/views/widget/page_content.html

@@ -25,6 +25,8 @@
   data-page-has-children="{% if pages.length > 0 %}true{% else %}false{% endif %}"
   data-page-user="{% if pageUser %}{{ pageUser|json }}{% else %}null{% endif %}"
   data-share-links-number="{% if page %}{{ sharelinksNumber }}{% endif %}"
+  data-share-link-id="{% if sharelink %}{{ sharelink._id|json }}{% endif %}"
+  data-page-user="{% if pageUser %}{{ pageUser|json }}{% else %}null{% endif %}"
   >
 {% else %}
 <div id="content-main" class="content-main"

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

@@ -65,9 +65,9 @@
 
   <!-- Outside-share-link -->
   {% if !isTrashPage() %}
-    <li id="page-share-management" class="nav-item dropdown"></li>
+    <li id="page-share-management" class="nav-item dropdown d-edit-none"></li>
   {% endif %}
-  
+
   <!-- icon-options-vertical -->
   {% if !isTrashPage() %}
     <li id="page-management" class="nav-item dropdown d-edit-none"></li>

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

@@ -61,7 +61,7 @@
   </li>
 
   {% if !isTrashPage() %}
-    <li id="page-management" class="nav-item dropdown"></li>
+    <li id="page-management" class="nav-item dropdown d-edit-none"></li>
   {% endif %}
 
 </ul>

+ 83 - 0
src/test/middlewares/access-token-parser.test.js

@@ -0,0 +1,83 @@
+const mongoose = require('mongoose');
+
+const { getInstance } = require('../setup-crowi');
+
+describe('accessTokenParser', () => {
+  let crowi;
+  let accessTokenParser;
+
+  let User;
+  let targetUser;
+
+  beforeAll(async(done) => {
+    crowi = await getInstance();
+    User = mongoose.model('User');
+    accessTokenParser = require('@server/middlewares/access-token-parser')(crowi);
+
+    targetUser = await User.create({
+      name: 'Example for access token parser',
+      username: 'targetUser',
+      password: 'usertestpass',
+      lang: 'en_US',
+      apiToken: 'N4xPDjh48TBsC7ahUN+ajjL5asnGpwtA5VAR+EhIDeg=',
+    });
+
+
+    done();
+  });
+
+  crowi = {
+    model: jest.fn().mockReturnValue(User),
+  };
+  const req = {
+    skipCsrfVerify: false,
+    query: {},
+    body: {},
+    user: {},
+  };
+
+  const res = {};
+  const next = jest.fn().mockReturnValue('next');
+
+  test('without accessToken', async() => {
+    const result = await accessTokenParser(req, res, next);
+
+    expect(next).toHaveBeenCalled();
+    expect(result).toBe('next');
+    expect(req.skipCsrfVerify).toBe(false);
+  });
+
+  test('with invalid accessToken', async() => {
+    req.query.access_token = 'invalidAccessToken';
+
+    const result = await accessTokenParser(req, res, next);
+
+    expect(next).toHaveBeenCalled();
+    expect(result).toBe('next');
+    expect(req.skipCsrfVerify).toBe(false);
+  });
+
+  test('with accessToken in query', async() => {
+    req.query.access_token = 'N4xPDjh48TBsC7ahUN+ajjL5asnGpwtA5VAR+EhIDeg=';
+
+    const result = await accessTokenParser(req, res, next);
+
+    expect(next).toHaveBeenCalled();
+    expect(result).toBe('next');
+    expect(req.skipCsrfVerify).toBe(true);
+    expect(req.user._id).toStrictEqual(targetUser._id);
+  });
+
+  test('with accessToken in body', async() => {
+    req.body.access_token = 'N4xPDjh48TBsC7ahUN+ajjL5asnGpwtA5VAR+EhIDeg=';
+
+    const result = await accessTokenParser(req, res, next);
+
+    expect(next).toHaveBeenCalled();
+    expect(result).toBe('next');
+    expect(req.skipCsrfVerify).toBe(true);
+    expect(req.user._id).toStrictEqual(targetUser._id);
+  });
+
+
+});

+ 16 - 0
src/test/middlewares/login-required.test.js

@@ -52,6 +52,22 @@ describe('loginRequired', () => {
       expect(result).toBe('redirect');
     });
 
+    test('pass anyone into sharedPage when aclService.isGuestAllowedToRead() returns false', () => {
+
+      req.isSharedPage = true;
+
+      // prepare spy for AclService.isGuestAllowedToRead
+      const isGuestAllowedToReadSpy = jest.spyOn(crowi.aclService, 'isGuestAllowedToRead')
+        .mockImplementation(() => false);
+
+      const result = loginRequired(req, res, next);
+
+      expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
+      expect(next).toHaveBeenCalled();
+      expect(res.redirect).not.toHaveBeenCalled();
+      expect(result).toBe('next');
+    });
+
   });
 
 

Неке датотеке нису приказане због велике количине промена