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

Merge branch 'master' into imprv/gw3918-border-redesign-for-master-marge

# Conflicts:
#	src/client/js/components/PageAccessoriesModalControl.jsx
#	src/client/styles/scss/theme/_apply-colors-dark.scss
#	src/client/styles/scss/theme/_apply-colors-light.scss
oshikishintaro 5 лет назад
Родитель
Сommit
d44692a353
89 измененных файлов с 607 добавлено и 817 удалено
  1. 15 0
      .github/workflows/release-rc.yml
  2. 24 7
      .github/workflows/release.yml
  3. 5 0
      CHANGES.md
  4. 2 2
      bin/github-actions/update-readme.sh
  5. 5 5
      docker/README.md
  6. 4 1
      resource/locales/en_US/translation.json
  7. 4 1
      resource/locales/ja_JP/translation.json
  8. 5 2
      resource/locales/zh_CN/translation.json
  9. 30 3
      src/client/js/components/Admin/Notification/NotificationSetting.jsx
  10. 0 98
      src/client/js/components/Admin/Notification/NotificationSettingContents.jsx
  11. 87 166
      src/client/js/components/Admin/Security/SecurityManagementContents.jsx
  12. 1 1
      src/client/js/components/Admin/Security/SecuritySetting.jsx
  13. 9 1
      src/client/js/components/CustomNavigation.jsx
  14. 12 4
      src/client/js/components/Fab.jsx
  15. 10 2
      src/client/js/components/Hotkeys/Subscribers/EditPage.jsx
  16. 1 1
      src/client/js/components/LikeButton.jsx
  17. 1 1
      src/client/js/components/Me/ApiSettings.jsx
  18. 1 1
      src/client/js/components/Me/PasswordSettings.jsx
  19. 40 51
      src/client/js/components/Me/PersonalSettings.jsx
  20. 4 4
      src/client/js/components/MyDraftList/Draft.jsx
  21. 2 3
      src/client/js/components/MyDraftList/MyDraftList.jsx
  22. 1 2
      src/client/js/components/Navbar/GrowiNavbar.jsx
  23. 9 5
      src/client/js/components/Navbar/GrowiSubNavigation.jsx
  24. 18 15
      src/client/js/components/NotFoundPage.jsx
  25. 1 1
      src/client/js/components/Page.jsx
  26. 2 2
      src/client/js/components/Page/PageManagement.jsx
  27. 1 1
      src/client/js/components/Page/ShareLinkAlert.jsx
  28. 1 1
      src/client/js/components/PageAccessoriesModal.jsx
  29. 6 4
      src/client/js/components/PageAccessoriesModalControl.jsx
  30. 1 1
      src/client/js/components/PageComments.jsx
  31. 3 3
      src/client/js/components/PageContentFooter.jsx
  32. 1 1
      src/client/js/components/SearchPage/SearchResult.jsx
  33. 1 1
      src/client/js/components/ShareLink/ShareLinkForm.jsx
  34. 21 9
      src/client/js/components/TableOfContents.jsx
  35. 11 9
      src/client/js/components/TrashPageList.jsx
  36. 1 1
      src/client/js/components/User/UserInfo.jsx
  37. 3 46
      src/client/js/legacy/crowi.js
  38. 4 0
      src/client/js/services/NavigationContainer.js
  39. 34 22
      src/client/js/services/PageContainer.js
  40. 7 3
      src/client/js/services/TagContainer.js
  41. 4 10
      src/client/styles/scss/_layout.scss
  42. 1 2
      src/client/styles/scss/_page-accessories-control.scss
  43. 1 26
      src/client/styles/scss/_page.scss
  44. 0 10
      src/client/styles/scss/_page_list.scss
  45. 1 0
      src/client/styles/scss/_search.scss
  46. 1 1
      src/client/styles/scss/_subnav.scss
  47. 10 0
      src/client/styles/scss/_user.scss
  48. 0 6
      src/client/styles/scss/_user_growi.scss
  49. 10 1
      src/client/styles/scss/atoms/_buttons.scss
  50. 0 1
      src/client/styles/scss/style-app.scss
  51. 2 4
      src/client/styles/scss/theme/_apply-colors-dark.scss
  52. 2 4
      src/client/styles/scss/theme/_apply-colors-light.scss
  53. 3 2
      src/client/styles/scss/theme/_apply-colors.scss
  54. 1 1
      src/client/styles/scss/theme/halloween.scss
  55. 4 0
      src/client/styles/scss/theme/spring.scss
  56. 2 2
      src/server/middlewares/login-required.js
  57. 43 36
      src/server/routes/apiv3/bookmarks.js
  58. 49 8
      src/server/routes/apiv3/page.js
  59. 0 1
      src/server/routes/index.js
  60. 10 76
      src/server/routes/page.js
  61. 0 3
      src/server/views/admin/app.html
  62. 0 2
      src/server/views/admin/customize.html
  63. 0 3
      src/server/views/admin/export.html
  64. 0 5
      src/server/views/admin/external-accounts.html
  65. 0 3
      src/server/views/admin/global-notification-detail.html
  66. 0 3
      src/server/views/admin/importer.html
  67. 0 3
      src/server/views/admin/index.html
  68. 0 10
      src/server/views/admin/markdown.html
  69. 0 3
      src/server/views/admin/notification.html
  70. 0 3
      src/server/views/admin/search.html
  71. 0 3
      src/server/views/admin/security.html
  72. 0 3
      src/server/views/admin/user-group-detail.html
  73. 0 5
      src/server/views/admin/user-groups.html
  74. 0 5
      src/server/views/admin/users.html
  75. 25 19
      src/server/views/layout-growi/base/layout.html
  76. 1 5
      src/server/views/layout-growi/forbidden.html
  77. 1 5
      src/server/views/layout-growi/not_creatable.html
  78. 1 5
      src/server/views/layout-growi/not_found.html
  79. 1 3
      src/server/views/layout-growi/page.html
  80. 3 1
      src/server/views/layout-growi/page_list.html
  81. 4 11
      src/server/views/layout-growi/user_page.html
  82. 1 4
      src/server/views/layout/admin.html
  83. 15 12
      src/server/views/me/drafts.html
  84. 15 13
      src/server/views/me/index.html
  85. 0 3
      src/server/views/search.html
  86. 0 3
      src/server/views/tags.html
  87. 1 1
      src/server/views/widget/not_creatable_content.html
  88. 5 4
      src/server/views/widget/page_content.html
  89. 2 2
      src/test/middlewares/login-required.test.js

+ 15 - 0
.github/workflows/release-rc.yml

@@ -45,6 +45,21 @@ jobs:
         semver: ${{ env.SEMVER }}
         publish: true
 
+    - name: Login to GitHub Container Registry
+      uses: docker/login-action@v1
+      with:
+        registry: ghcr.io
+        username: wsmoogle
+        password: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
+
+    - name: Docker Tags by SemVer in Github Container Registry
+      uses: weseek/ghaction-docker-tags-by-semver@v1.0.3
+      with:
+        source: growi
+        target: ghcr.io/weseek/growi
+        semver: ${{ env.SEMVER }}
+        publish: true
+
     - name: Check whether workspace is clean
       run: |
         STATUS=`git status --porcelain`

+ 24 - 7
.github/workflows/release.yml

@@ -97,13 +97,6 @@ jobs:
         additional-tags: 'latest'
         publish: true
 
-    - name: Slack Notification
-      uses: weseek/ghaction-release-slack-notification@master
-      with:
-        channel: '#general'
-        url: ${{ secrets.SLACK_WEBHOOK_URL }}
-        created_tag: 'v${{ needs.github-release.outputs.RELEASE_VERSION }}${{ env.SUFFIX }}'
-
     - name: Update Docker Hub Description
       uses: peter-evans/dockerhub-description@v2
       with:
@@ -112,6 +105,30 @@ jobs:
         repository: weseek/growi
         readme-filepath: ./docker/README.md
 
+    - name: Login to GitHub Container Registry
+      uses: docker/login-action@v1
+      with:
+        registry: ghcr.io
+        username: wsmoogle
+        password: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
+
+    - name: Docker Tags by SemVer in Github Container Registry
+      uses: weseek/ghaction-docker-tags-by-semver@v1.0.3
+      with:
+        source: growi${{ env.SUFFIX }}
+        target: ghcr.io/weseek/growi
+        semver: ${{ needs.github-release.outputs.RELEASE_VERSION }}
+        suffix: ${{ env.SUFFIX }}
+        additional-tags: 'latest'
+        publish: true
+
+    - name: Slack Notification
+      uses: weseek/ghaction-release-slack-notification@master
+      with:
+        channel: '#general'
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}
+        created_tag: 'v${{ needs.github-release.outputs.RELEASE_VERSION }}${{ env.SUFFIX }}'
+
     - name: Check whether workspace is clean
       run: |
         STATUS=`git status --porcelain`

+ 5 - 0
CHANGES.md

@@ -15,6 +15,11 @@
     * migrate-mongo
     * mongoose
 
+## v4.1.10
+
+* Fix: Make listing users API secure
+* Fix: Error message when the server denies guest user connecting with socket.io
+
 ## v4.1.9
 
 * Feature: Environment variables to set max connection size to deliver push messages to all clients

+ 2 - 2
bin/github-actions/update-readme.sh

@@ -2,5 +2,5 @@
 
 cd docker
 
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.1\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}\2\3${RELEASE_VERSION}\4/" README.md
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.1-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}-nocdn\2\3${RELEASE_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.2\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}\2\3${RELEASE_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.2-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}-nocdn\2\3${RELEASE_VERSION}\4/" README.md

+ 5 - 5
docker/README.md

@@ -10,10 +10,10 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`4.1.0`, `4.1`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.1.0/docker/Dockerfile)
-* [`4.1.0-nocdn`, `4.1-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.1.0/docker/Dockerfile)
-* [`4.0.11`, `4.0`(Dockerfile)](https://github.com/weseek/growi/blob/v4.0.11/docker/Dockerfile)
-* [`4.0.11-nocdn`, `4.0-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.0.11/docker/Dockerfile)
+* [`4.2.0`, `4.2`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.2.0/docker/Dockerfile)
+* [`4.2.0-nocdn`, `4.2-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.2.0/docker/Dockerfile)
+* [`4.1.10`, `4.1` (Dockerfile)](https://github.com/weseek/growi/blob/v4.1.10/docker/Dockerfile)
+* [`4.1.10-nocdn`, `4.1-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.1.10/docker/Dockerfile)
 * [`3.8.0`, `3.8`, `3` (Dockerfile)](https://github.com/weseek/growi/blob/v3.8.0/docker/Dockerfile)
 * [`3.8.0-nocdn`, `3.8-nocdn`, `3-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v3.8.0/docker/Dockerfile)
 
@@ -39,7 +39,7 @@ The GROWI official docker image for production use which concludes several offic
 Requirements
 -------------
 
-* MongoDB (>= 3.6)
+* MongoDB (>= 4.4)
 
 ### Optional Dependencies
 

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

@@ -99,7 +99,6 @@
   "Input page name (optional)": "Input page name (optional)",
   "New Page": "New page",
   "Create under": "Create page under below:",
-  "Table of Contents": "Table of Contents",
   "Wiki Management Home Page": "Wiki Management Home Page",
   "App Settings": "App Settings",
   "Site URL settings": "Site URL settings",
@@ -191,6 +190,7 @@
     }
   },
   "page_me_apitoken": {
+    "api_token": "API Token",
     "notice": {
       "apitoken_issued": "API token is not issued.",
       "update_token1": "You can update to generate a new API token.",
@@ -295,6 +295,9 @@
       "no_deadline":"This page has no expiration date"
     }
   },
+  "page_table_of_contents": {
+    "empty": "Table of Contents is empty"
+  },
   "page_edit": {
     "Show active line": "Show active line",
     "overwrite_scopes": "{{operation}} and Overwrite scopes of all descendants",

+ 4 - 1
resource/locales/ja_JP/translation.json

@@ -100,7 +100,6 @@
   "Input page name (optional)": "ページ名を入力(空欄OK)",
   "New Page": "新規ページ",
   "Create under": "ページを以下に作成",
-  "Table of Contents": "目次",
   "Wiki Management Home Page": "Wiki管理トップ",
   "App Settings": "アプリ設定",
   "Site URL settings": "サイトURL設定",
@@ -194,6 +193,7 @@
     }
   },
   "page_me_apitoken": {
+    "api_token": "API Token",
     "notice": {
       "apitoken_issued": "API Token が設定されていません。",
       "update_token1": "API Token を更新すると、自動的に新しい Token が生成されます。",
@@ -297,6 +297,9 @@
       "no_deadline": "このページに有効期限は設定されていません。"
     }
   },
+  "page_table_of_contents": {
+    "empty": "目次は空です"
+  },
   "page_edit": {
     "Show active line": "アクティブ行をハイライト",
     "overwrite_scopes": "{{operation}}と同時に全ての配下ページのスコープを上書き",

+ 5 - 2
resource/locales/zh_CN/translation.json

@@ -108,7 +108,6 @@
 	"Input page name (optional)": "Input page name (optional)",
 	"New Page": "新页面",
 	"Create under": "Create page under below:",
-	"Table of Contents": "Table of Contents",
 	"Wiki Management Home Page": "Wiki管理首页",
 	"App Settings": "系统设置",
 	"Site URL settings": "主页URL设置",
@@ -193,6 +192,7 @@
 		}
 	},
 	"page_me_apitoken": {
+    "api_token": "API Token",
 		"notice": {
 			"apitoken_issued": "API token 未发布。",
 			"update_token1": "您可以更新以生成新的API令牌。",
@@ -282,7 +282,10 @@
 		"notice": {
 			"conflict": "无法保存您所做的更改,因为其他人正在编辑此页。请在重新加载页面后重新编辑受影响的部分。"
 		}
-	},
+  },
+  "page_table_of_contents": {
+    "empty": "目录为空"
+  },
   "page_comment": {
     "display_the_page_when_posting_this_comment": "Display the page when posting this comment"
   },

+ 30 - 3
src/client/js/components/Admin/Notification/NotificationSetting.jsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useMemo } from 'react';
 import PropTypes from 'prop-types';
 
 import loggerFactory from '@alias/logger';
@@ -10,7 +10,11 @@ import { withLoadingSppiner } from '../../SuspenseUtils';
 
 import AdminNotificationContainer from '../../../services/AdminNotificationContainer';
 
-import NotificationSettingContents from './NotificationSettingContents';
+import CustomNavigation from '../../CustomNavigation';
+
+import SlackAppConfiguration from './SlackAppConfiguration';
+import UserTriggerNotification from './UserTriggerNotification';
+import GlobalNotification from './GlobalNotification';
 
 const logger = loggerFactory('growi:NotificationSetting');
 
@@ -36,7 +40,30 @@ function NotificationSetting(props) {
     throw new Error(`${retrieveErrors.length} errors occured`);
   }
 
-  return <NotificationSettingContents />;
+  const navTabMapping = useMemo(() => {
+    return {
+      slack_configuration: {
+        Icon: () => <i className="icon-settings" />,
+        Content: SlackAppConfiguration,
+        i18n: 'Slack configuration',
+        index: 0,
+      },
+      user_trigger_notification: {
+        Icon: () => <i className="icon-settings" />,
+        Content: UserTriggerNotification,
+        i18n: 'User trigger notification',
+        index: 1,
+      },
+      global_notification: {
+        Icon: () => <i className="icon-settings" />,
+        Content: GlobalNotification,
+        i18n: 'Global notification',
+        index: 2,
+      },
+    };
+  }, []);
+
+  return <CustomNavigation navTabMapping={navTabMapping} />;
 }
 
 const NotificationSettingWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(NotificationSetting), [AdminNotificationContainer]);

+ 0 - 98
src/client/js/components/Admin/Notification/NotificationSettingContents.jsx

@@ -1,98 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import {
-  TabContent, TabPane, Nav, NavItem, NavLink,
-} from 'reactstrap';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-import AppContainer from '../../../services/AppContainer';
-import AdminNotificationContainer from '../../../services/AdminNotificationContainer';
-
-import SlackAppConfiguration from './SlackAppConfiguration';
-import UserTriggerNotification from './UserTriggerNotification';
-import GlobalNotification from './GlobalNotification';
-
-
-class NotificationSettingContents extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      activeTab: 'slack-configuration',
-      // Prevent unnecessary rendering
-      activeComponents: new Set(['slack-configuration']),
-    };
-
-    this.toggleActiveTab = this.toggleActiveTab.bind(this);
-  }
-
-  toggleActiveTab(activeTab) {
-    this.setState({
-      activeTab, activeComponents: this.state.activeComponents.add(activeTab),
-    });
-  }
-
-  render() {
-    const { activeTab, activeComponents } = this.state;
-
-    return (
-      <React.Fragment>
-        <Nav tabs>
-          <NavItem>
-            <NavLink
-              className={`${activeTab === 'slack-configuration' && 'active'} `}
-              onClick={() => { this.toggleActiveTab('slack-configuration') }}
-              href="#slack-configuration"
-            >
-              <i className="icon-settings"></i> Slack configuration
-            </NavLink>
-          </NavItem>
-          <NavItem>
-            <NavLink
-              className={`${activeTab === 'user-trigger-notification' && 'active'} `}
-              onClick={() => { this.toggleActiveTab('user-trigger-notification') }}
-              href="#user-trigger-notification"
-            >
-              <i className="icon-settings"></i> User trigger notification
-            </NavLink>
-          </NavItem>
-          <NavItem>
-            <NavLink
-              className={`${activeTab === 'global-notification' && 'active'} `}
-              onClick={() => { this.toggleActiveTab('global-notification') }}
-              href="#global-notification"
-            >
-              <i className="icon-settings"></i> Global notification
-            </NavLink>
-          </NavItem>
-        </Nav>
-        <TabContent activeTab={activeTab}>
-          <TabPane tabId="slack-configuration">
-            {activeComponents.has('slack-configuration') && <SlackAppConfiguration />}
-          </TabPane>
-          <TabPane tabId="user-trigger-notification">
-            {activeComponents.has('user-trigger-notification') && <UserTriggerNotification />}
-          </TabPane>
-          <TabPane tabId="global-notification">
-            {activeComponents.has('global-notification') && <GlobalNotification />}
-          </TabPane>
-        </TabContent>
-      </React.Fragment>
-    );
-  }
-
-}
-
-const NotificationSettingContentsWrapper = withUnstatedContainers(NotificationSettingContents, [AppContainer, AdminNotificationContainer]);
-
-NotificationSettingContents.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminNotificationContainer: PropTypes.instanceOf(AdminNotificationContainer).isRequired,
-
-};
-
-export default withTranslation()(NotificationSettingContentsWrapper);

+ 87 - 166
src/client/js/components/Admin/Security/SecurityManagementContents.jsx

@@ -1,13 +1,7 @@
-import React, { Fragment } from 'react';
+import React, { Fragment, useMemo } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-import {
-  TabContent, TabPane, Nav, NavItem, NavLink,
-} from 'reactstrap';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-import AppContainer from '../../../services/AppContainer';
 import LdapSecuritySetting from './LdapSecuritySetting';
 import LocalSecuritySetting from './LocalSecuritySetting';
 import SamlSecuritySetting from './SamlSecuritySetting';
@@ -20,177 +14,104 @@ import TwitterSecuritySetting from './TwitterSecuritySetting';
 import FacebookSecuritySetting from './FacebookSecuritySetting';
 import ShareLinkSetting from './ShareLinkSetting';
 
-class SecurityManagementContents extends React.Component {
-
-  constructor() {
-    super();
-
-    this.state = {
-      activeTab: 'passport-local',
-      // Prevent unnecessary rendering
-      activeComponents: new Set(['passport-local']),
+import CustomNavigation from '../../CustomNavigation';
+
+function SecurityManagementContents(props) {
+  const { t } = props;
+
+  const navTabMapping = useMemo(() => {
+    return {
+      passport_local: {
+        Icon: () => <i className="fa fa-users" />,
+        Content: LocalSecuritySetting,
+        i18n: 'ID/Pass',
+        index: 0,
+      },
+      passport_ldap: {
+        Icon: () => <i className="fa fa-sitemap" />,
+        Content: LdapSecuritySetting,
+        i18n: 'LDAP',
+        index: 1,
+      },
+      passport_saml: {
+        Icon: () => <i className="fa fa-key" />,
+        Content: SamlSecuritySetting,
+        i18n: 'SAML',
+        index: 2,
+      },
+      passport_oidc: {
+        Icon: () => <i className="fa fa-key" />,
+        Content: OidcSecuritySetting,
+        i18n: 'OIDC',
+        index: 3,
+      },
+      passport_basic: {
+        Icon: () => <i className="fa fa-lock" />,
+        Content: BasicSecuritySetting,
+        i18n: 'BASIC',
+        index: 4,
+      },
+      passport_google: {
+        Icon: () => <i className="fa fa-google" />,
+        Content: GoogleSecuritySetting,
+        i18n: 'Google',
+        index: 5,
+      },
+      passport_github: {
+        Icon: () => <i className="fa fa-github" />,
+        Content: GitHubSecuritySetting,
+        i18n: 'GitHub',
+        index: 6,
+      },
+      passport_twitter: {
+        Icon: () => <i className="fa fa-twitter" />,
+        Content: TwitterSecuritySetting,
+        i18n: 'Twitter',
+        index: 7,
+      },
+      passport_facebook: {
+        Icon: () => <i className="fa fa-facebook" />,
+        Content: FacebookSecuritySetting,
+        i18n: '(TBD) Facebook',
+        index: 8,
+      },
     };
+  }, []);
 
-    this.toggleActiveTab = this.toggleActiveTab.bind(this);
-  }
-
-  toggleActiveTab(activeTab) {
-    this.setState({
-      activeTab, activeComponents: this.state.activeComponents.add(activeTab),
-    });
-  }
 
-  render() {
-    const { t } = this.props;
-    const { activeTab, activeComponents } = this.state;
-    return (
-      <Fragment>
-        <div className="mb-5">
-          <SecuritySetting />
-        </div>
+  return (
+    <Fragment>
+      <div className="mb-5">
+        <SecuritySetting />
+      </div>
 
-        {/* Shared Link List */}
-        <div className="mb-5">
-          <ShareLinkSetting />
-        </div>
+      {/* Shared Link List */}
+      <div className="mb-5">
+        <ShareLinkSetting />
+      </div>
 
 
-        {/* XSS configuration link */}
-        <div className="mb-5">
-          <h2 className="border-bottom">{t('security_setting.xss_prevent_setting')}</h2>
-          <div className="text-center">
-            <a style={{ fontSize: 'large' }} href="/admin/markdown/#preventXSS">
-              <i className="fa-fw icon-login"></i> {t('security_setting.xss_prevent_setting_link')}
-            </a>
-          </div>
+      {/* XSS configuration link */}
+      <div className="mb-5">
+        <h2 className="border-bottom">{t('security_setting.xss_prevent_setting')}</h2>
+        <div className="text-center">
+          <a style={{ fontSize: 'large' }} href="/admin/markdown/#preventXSS">
+            <i className="fa-fw icon-login"></i> {t('security_setting.xss_prevent_setting_link')}
+          </a>
         </div>
+      </div>
 
-        <div className="auth-mechanism-configurations">
-          <h2 className="border-bottom">{t('security_setting.Authentication mechanism settings')}</h2>
-          <Nav tabs>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-local' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-local') }}
-                href="#passport-local"
-              >
-                <i className="fa fa-users" /> ID/Pass
-              </NavLink>
-            </NavItem>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-ldap' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-ldap') }}
-                href="#passport-ldap"
-              >
-                <i className="fa fa-sitemap" /> LDAP
-              </NavLink>
-            </NavItem>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-saml' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-saml') }}
-                href="#passport-saml"
-              >
-                <i className="fa fa-key" /> SAML
-              </NavLink>
-            </NavItem>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-oidc' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-oidc') }}
-                href="#passport-oidc"
-              >
-                <i className="fa fa-openid" /> OIDC
-              </NavLink>
-            </NavItem>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-basic' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-basic') }}
-                href="#passport-basic"
-              >
-                <i className="fa fa-lock" /> BASIC
-              </NavLink>
-            </NavItem>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-google' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-google') }}
-                href="#passport-google"
-              >
-                <i className="fa fa-google" /> Google
-              </NavLink>
-            </NavItem>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-github' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-github') }}
-                href="#passport-github"
-              >
-                <i className="fa fa-github" /> GitHub
-              </NavLink>
-            </NavItem>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-twitter' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-twitter') }}
-                href="#passport-twitter"
-              >
-                <i className="fa fa-twitter" /> Twitter
-              </NavLink>
-            </NavItem>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-facebook' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-facebook') }}
-                href="#passport-facebook"
-              >
-                <i className="fa fa-facebook" /> (TBD) Facebook
-              </NavLink>
-            </NavItem>
-          </Nav>
-          <TabContent activeTab={activeTab} className="mt-2">
-            <TabPane tabId="passport-local">
-              {activeComponents.has('passport-local') && <LocalSecuritySetting />}
-            </TabPane>
-            <TabPane tabId="passport-ldap">
-              {activeComponents.has('passport-ldap') && <LdapSecuritySetting />}
-            </TabPane>
-            <TabPane tabId="passport-saml">
-              {activeComponents.has('passport-saml') && <SamlSecuritySetting />}
-            </TabPane>
-            <TabPane tabId="passport-oidc">
-              {activeComponents.has('passport-oidc') && <OidcSecuritySetting />}
-            </TabPane>
-            <TabPane tabId="passport-basic">
-              {activeComponents.has('passport-basic') && <BasicSecuritySetting />}
-            </TabPane>
-            <TabPane tabId="passport-google">
-              {activeComponents.has('passport-google') && <GoogleSecuritySetting />}
-            </TabPane>
-            <TabPane tabId="passport-github">
-              {activeComponents.has('passport-github') && <GitHubSecuritySetting />}
-            </TabPane>
-            <TabPane tabId="passport-twitter">
-              {activeComponents.has('passport-twitter') && <TwitterSecuritySetting />}
-            </TabPane>
-            <TabPane tabId="passport-facebook">
-              {activeComponents.has('passport-facebook') && <FacebookSecuritySetting />}
-            </TabPane>
-          </TabContent>
-        </div>
-      </Fragment>
-    );
-  }
+      <div className="auth-mechanism-configurations">
+        <h2 className="border-bottom">{t('security_setting.Authentication mechanism settings')}</h2>
+        <CustomNavigation navTabMapping={navTabMapping} />
+      </div>
+    </Fragment>
+  );
 
 }
 
 SecurityManagementContents.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 };
 
-const SecurityManagementContentsWrapper = withUnstatedContainers(SecurityManagementContents, [AppContainer]);
-
-export default withTranslation()(SecurityManagementContentsWrapper);
+export default withTranslation()(SecurityManagementContents);

+ 1 - 1
src/client/js/components/Admin/Security/SecuritySetting.jsx

@@ -63,7 +63,7 @@ class SecuritySetting extends React.Component {
               <td>{ t('always_hidden') }</td>
             </tr>
             <tr>
-              <th scope="row">{ t('Just me') }</th>
+              <th scope="row">{ t('Only me') }</th>
               <td>
                 <div className="custom-control custom-switch custom-checkbox-success">
                   <input

+ 9 - 1
src/client/js/components/CustomNavigation.jsx

@@ -12,7 +12,9 @@ export const CustomNav = (props) => {
   const [sliderWidth, setSliderWidth] = useState(0);
   const [sliderMarginLeft, setSliderMarginLeft] = useState(0);
 
-  const { activeTab, navTabMapping, onNavSelected } = props;
+  const {
+    activeTab, navTabMapping, onNavSelected, hideBorderBottom,
+  } = props;
 
   const navTabRefs = useMemo(() => {
     const obj = {};
@@ -87,6 +89,7 @@ export const CustomNav = (props) => {
         </Nav>
       </div>
       <hr className="my-0 grw-nav-slide-hr border-none" style={{ width: `${sliderWidth}%`, marginLeft: `${sliderMarginLeft}%` }} />
+      { !hideBorderBottom && <hr className="my-0 border-top-0 border-bottom" /> }
     </div>
   );
 
@@ -96,6 +99,11 @@ CustomNav.propTypes = {
   activeTab: PropTypes.string.isRequired,
   navTabMapping: PropTypes.object.isRequired,
   onNavSelected: PropTypes.func,
+  hideBorderBottom: PropTypes.bool,
+};
+
+CustomNav.defaultProps = {
+  hideBorderBottom: false,
 };
 
 

+ 12 - 4
src/client/js/components/Fab.jsx

@@ -17,13 +17,17 @@ const Fab = (props) => {
   const { currentUser } = appContainer;
 
   const [animateClasses, setAnimateClasses] = useState('invisible');
+  const [buttonClasses, setButtonClasses] = useState('');
 
 
   const stickyChangeHandler = useCallback((event) => {
     logger.debug('StickyEvents.CHANGE detected');
 
-    const classes = event.detail.isSticky ? 'animated fadeInUp faster' : 'animated fadeOut faster';
-    setAnimateClasses(classes);
+    const newAnimateClasses = event.detail.isSticky ? 'animated fadeInUp faster' : 'animated fadeOut faster';
+    const newButtonClasses = event.detail.isSticky ? '' : 'disabled grw-pointer-events-none';
+
+    setAnimateClasses(newAnimateClasses);
+    setButtonClasses(newButtonClasses);
   }, []);
 
   // setup effect by sticky event
@@ -47,7 +51,7 @@ const Fab = (props) => {
         <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: '2.3rem', right: '4rem' }}>
           <button
             type="button"
-            className="btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light"
+            className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light ${buttonClasses}`}
             onClick={navigationContainer.openPageCreateModal}
           >
             <CreatePageIcon />
@@ -61,7 +65,11 @@ const Fab = (props) => {
     <div className="grw-fab d-none d-md-block">
       {currentUser != null && renderPageCreateButton()}
       <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: 0, right: 0 }}>
-        <button type="button" className="btn btn-light btn-scroll-to-top rounded-circle p-0" onClick={() => navigationContainer.smoothScrollIntoView()}>
+        <button
+          type="button"
+          className={`btn btn-light btn-scroll-to-top rounded-circle p-0 ${buttonClasses}`}
+          onClick={() => navigationContainer.smoothScrollIntoView()}
+        >
           <ReturnTopIcon />
         </button>
       </div>

+ 10 - 2
src/client/js/components/Hotkeys/Subscribers/EditPage.jsx

@@ -1,6 +1,9 @@
 import React, { useEffect } from 'react';
 import PropTypes from 'prop-types';
 
+import NavigationContainer from '../../../services/NavigationContainer';
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
 const EditPage = (props) => {
 
   // setup effect
@@ -10,6 +13,8 @@ const EditPage = (props) => {
       return;
     }
 
+    props.navigationContainer.setEditorMode('edit');
+
     // remove this
     props.onDeleteRender(this);
   }, [props]);
@@ -18,11 +23,14 @@ const EditPage = (props) => {
 };
 
 EditPage.propTypes = {
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   onDeleteRender: PropTypes.func.isRequired,
 };
 
-EditPage.getHotkeyStrokes = () => {
+const EditPageWrapper = withUnstatedContainers(EditPage, [NavigationContainer]);
+
+EditPageWrapper.getHotkeyStrokes = () => {
   return [['e']];
 };
 
-export default EditPage;
+export default EditPageWrapper;

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

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

+ 1 - 1
src/client/js/components/Me/ApiSettings.jsx

@@ -25,7 +25,7 @@ class ApiSettings extends React.Component {
       await appContainer.apiv3Put('/personal-setting/api-token');
 
       await personalContainer.retrievePersonalData();
-      toastSuccess(t('toaster.update_successed', { target: t('personal_settings.update_password') }));
+      toastSuccess(t('toaster.update_successed', { target: t('page_me_apitoken.api_token') }));
     }
     catch (err) {
       toastError(err);

+ 1 - 1
src/client/js/components/Me/PasswordSettings.jsx

@@ -55,7 +55,7 @@ class PasswordSettings extends React.Component {
       });
       this.setState({ oldPassword: '', newPassword: '', newPasswordConfirm: '' });
       await personalContainer.retrievePersonalData();
-      toastSuccess(t('toaster.update_successed', { target: t('personal_settings.update_password') }));
+      toastSuccess(t('toaster.update_successed', { target: t('Password') }));
     }
     catch (err) {
       toastError(err);

+ 40 - 51
src/client/js/components/Me/PersonalSettings.jsx

@@ -1,63 +1,52 @@
 
-import React, { Fragment } from 'react';
+import React, { useMemo } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-
+import CustomNavigation from '../CustomNavigation';
 import UserSettings from './UserSettings';
 import PasswordSettings from './PasswordSettings';
 import ExternalAccountLinkedMe from './ExternalAccountLinkedMe';
 import ApiSettings from './ApiSettings';
 
-class PersonalSettings extends React.Component {
-
-  render() {
-    const { t } = this.props;
+const PersonalSettings = (props) => {
+
+  const { t } = props;
+
+  const navTabMapping = useMemo(() => {
+    return {
+      user_infomation: {
+        Icon: () => <i className="icon-fw icon-user"></i>,
+        Content: UserSettings,
+        i18n: t('User Information'),
+        index: 0,
+      },
+      external_accounts: {
+        Icon: () => <i className="icon-fw icon-share-alt"></i>,
+        Content: ExternalAccountLinkedMe,
+        i18n: t('admin:user_management.external_accounts'),
+        index: 1,
+      },
+      password_settings: {
+        Icon: () => <i className="icon-fw icon-lock"></i>,
+        Content: PasswordSettings,
+        i18n: t('Password Settings'),
+        index: 2,
+      },
+      api_settings: {
+        Icon: () => <i className="icon-fw icon-paper-plane"></i>,
+        Content: ApiSettings,
+        i18n: t('API Settings'),
+        index: 3,
+      },
+    };
+  }, [t]);
+
+
+  return (
+    <CustomNavigation navTabMapping={navTabMapping} />
+  );
 
-    return (
-      <Fragment>
-        <div className="personal-settings">
-          <ul className="nav nav-tabs" role="tablist">
-            <li className="nav-item">
-              <a className="nav-link active" href="#user-settings" data-toggle="tab" role="tab">
-                <i className="icon-fw icon-user"></i>{ t('User Information') }
-              </a>
-            </li>
-            <li className="nav-item">
-              <a className="nav-link" href="#external-accounts" data-toggle="tab" role="tab">
-                <i className="icon-fw icon-share-alt"></i>{ t('admin:user_management.external_accounts') }
-              </a>
-            </li>
-            <li className="nav-item">
-              <a className="nav-link" href="#password-settings" data-toggle="tab" role="tab">
-                <i className="icon-fw icon-lock"></i>{ t('Password Settings') }
-              </a>
-            </li>
-            <li className="nav-item">
-              <a className="nav-link" href="#apiToken" data-toggle="tab" role="tab">
-                <i className="icon-fw icon-paper-plane"></i>{ t('API Settings') }
-              </a>
-            </li>
-          </ul>
-          <div className="tab-content p-t-10">
-            <div id="user-settings" className="tab-pane active" role="tabpanel">
-              <UserSettings />
-            </div>
-            <div id="external-accounts" className="tab-pane" role="tabpanel">
-              <ExternalAccountLinkedMe />
-            </div>
-            <div id="password-settings" className="tab-pane" role="tabpanel">
-              <PasswordSettings />
-            </div>
-            <div id="apiToken" className="tab-pane" role="tabpanel">
-              <ApiSettings />
-            </div>
-          </div>
-        </div>
-      </Fragment>
-    );
-  }
-
-}
+};
 
 PersonalSettings.propTypes = {
   t: PropTypes.func.isRequired, // i18next

+ 4 - 4
src/client/js/components/MyDraftList/Draft.jsx

@@ -105,10 +105,9 @@ class Draft extends React.Component {
   }
 
   renderControls() {
-    const { t, path } = this.props;
+    const { t, path, index } = this.props;
 
-    const encodedPath = path.replace(/\//g, '-');
-    const tooltipTargetId = `draft-copied-tooltip_${encodedPath}`;
+    const tooltipTargetId = `draft-copied-tooltip_${index}`;
 
     return (
       <div className="icon-container">
@@ -116,7 +115,7 @@ class Draft extends React.Component {
           ? null
           : (
             <a
-              href={`${this.props.path}#edit`}
+              href={`${path}#edit`}
               target="_blank"
               rel="noopener noreferrer"
               data-toggle="tooltip"
@@ -203,6 +202,7 @@ Draft.propTypes = {
   t: PropTypes.func.isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
+  index: PropTypes.number.isRequired,
   path: PropTypes.string.isRequired,
   markdown: PropTypes.string.isRequired,
   isExist: PropTypes.bool.isRequired,

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

@@ -91,9 +91,10 @@ class MyDraftList extends React.Component {
    *
    */
   generateDraftList(drafts) {
-    return drafts.map((draft) => {
+    return drafts.map((draft, index) => {
       return (
         <Draft
+          index={index}
           key={draft.path}
           path={draft.path}
           markdown={draft.markdown}
@@ -135,8 +136,6 @@ class MyDraftList extends React.Component {
 
     return (
       <div className="page-list-container-create ">
-        <h1>My Drafts</h1>
-        <hr />
         { totalCount === 0
           && <span className="mt-2">No drafts yet.</span>
         }

+ 1 - 2
src/client/js/components/Navbar/GrowiNavbar.jsx

@@ -76,10 +76,9 @@ class GrowiNavbar extends React.Component {
         {/* Navbar Right  */}
         <ul className="navbar-nav ml-auto">
           {this.renderNavbarRight()}
+          {crowi.confidential != null && this.renderConfidential()}
         </ul>
 
-        {crowi.confidential != null && this.renderConfidential()}
-
         { isSearchServiceConfigured && !isDeviceSmallerThanMd && (
           <div className="grw-global-search grw-global-search-top position-absolute">
             <GlobalSearch />

+ 9 - 5
src/client/js/components/Navbar/GrowiSubNavigation.jsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useMemo } from 'react';
 import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
@@ -71,18 +71,22 @@ const PagePathNav = ({ pageId, pagePath, isPageForbidden }) => {
 const PageReactionButtons = ({ appContainer, pageContainer }) => {
 
   const {
-    pageId, isLiked, pageUser,
+    pageUser, shareLinkId,
   } = pageContainer.state;
 
+  const isSharedPage = useMemo(() => {
+    return shareLinkId != null;
+  }, [shareLinkId]);
+
   return (
     <>
-      {pageUser == null && (
+      {pageUser == null && !isSharedPage && (
       <span className="mr-2">
-        <LikeButton pageId={pageId} isLiked={isLiked} />
+        <LikeButton />
       </span>
       )}
       <span>
-        <BookmarkButton pageId={pageId} crowi={appContainer} />
+        <BookmarkButton crowi={appContainer} />
       </span>
     </>
   );

+ 18 - 15
src/client/js/components/NotFoundPage.jsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useMemo } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import PageListIcon from './Icons/PageListIcon';
@@ -10,20 +10,23 @@ import PageTimeline from './PageTimeline';
 const NotFoundPage = (props) => {
   const { t } = props;
 
-  const navTabMapping = {
-    pagelist: {
-      Icon: PageListIcon,
-      Content: PageList,
-      i18n: t('page_list'),
-      index: 0,
-    },
-    timeLine: {
-      Icon: TimeLineIcon,
-      Content: PageTimeline,
-      i18n: t('Timeline View'),
-      index: 1,
-    },
-  };
+  const navTabMapping = useMemo(() => {
+    return {
+      pagelist: {
+        Icon: PageListIcon,
+        Content: PageList,
+        i18n: t('page_list'),
+        index: 0,
+      },
+      timeLine: {
+        Icon: TimeLineIcon,
+        Content: PageTimeline,
+        i18n: t('Timeline View'),
+        index: 1,
+      },
+    };
+  }, [t]);
+
 
   return (
     <div className="mt-5 d-edit-none">

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

@@ -134,7 +134,7 @@ class Page extends React.Component {
     const { markdown } = pageContainer.state;
 
     return (
-      <div className={`${isMobile && 'page-mobile'}`}>
+      <div className={`mb-5 ${isMobile ? 'page-mobile' : ''}`}>
         <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} />
 
         { isLoggedIn && (

+ 2 - 2
src/client/js/components/Page/PageManagement.jsx

@@ -148,8 +148,8 @@ const PageManagement = (props) => {
     return (
       <>
         <div className="dropdown-divider"></div>
-        <button className="dropdown-item" type="button" onClick={openPageDeleteModalHandler}>
-          <i className="icon-fw icon-fire text-danger"></i> { t('Delete') }
+        <button className="dropdown-item text-danger" type="button" onClick={openPageDeleteModalHandler}>
+          <i className="icon-fw icon-fire"></i> { t('Delete') }
         </button>
       </>
     );

+ 1 - 1
src/client/js/components/Page/ShareLinkAlert.jsx

@@ -41,7 +41,7 @@ const ShareLinkAlert = (props) => {
   }
 
   return (
-    <p className={`alert alert-${specifyColor()} py-3 px-4`}>
+    <p className={`alert alert-${specifyColor()} py-3 px-4 d-edit-none`}>
       <i className="icon-fw icon-link"></i>
       {(expiredAt === '' ? <span>{t('page_page.notice.no_deadline')}</span>
       // eslint-disable-next-line react/no-danger

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

@@ -94,7 +94,7 @@ const PageAccessoriesModal = (props) => {
     <React.Fragment>
       <Modal size="xl" isOpen={props.isOpen} toggle={closeModalHandler} className={`grw-page-accessories-modal ${isWindowExpanded && 'grw-modal-expanded'} `}>
         <ModalHeader className="p-0" toggle={closeModalHandler} close={buttons}>
-          <CustomNav activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={switchActiveTab} />
+          <CustomNav activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={switchActiveTab} hideBorderBottom />
         </ModalHeader>
         <ModalBody className="overflow-auto grw-modal-body-style p-0">
           {/* Do not use CustomTabContent because of performance problem:

+ 6 - 4
src/client/js/components/PageAccessoriesModalControl.jsx

@@ -19,7 +19,8 @@ const PageAccessoriesModalControl = (props) => {
   const { t, pageAccessoriesContainer, isGuestUserMode } = props;
 
   return (
-    <div className="grw-page-accessories-control d-flex align-items-center pb-1">
+    <div className="grw-page-accessories-control d-flex align-items-center justify-content-between pb-1">
+
       <button
         type="button"
         className="btn btn-link grw-btn-page-accessories"
@@ -67,9 +68,10 @@ const PageAccessoriesModalControl = (props) => {
         </UncontrolledTooltip>
       )}
 
-      <span className="grw-border-vr mx-1">&nbsp;</span>
-
-      <SeenUserInfo />
+      <div className="d-flex align-items-center">
+        <span className="border-left grw-border-vr">&nbsp;</span>
+        <SeenUserInfo />
+      </div>
     </div>
   );
 };

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

@@ -148,7 +148,7 @@ class PageComments extends React.Component {
     }
 
     return (
-      <div key={commentId} className={`mb-5 ${rootClassNames}`}>
+      <div key={commentId} className={rootClassNames}>
         <Comment
           comment={comment}
           deleteBtnClicked={this.confirmToDeleteComment}

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

@@ -14,12 +14,12 @@ const PageContentFooter = (props) => {
   } = pageContainer.state;
 
   return (
-    <div className="page-content-footer mt-5 py-4 d-edit-none d-print-none">
+    <div className="page-content-footer py-4 d-edit-none d-print-none">
       <div className="container-lg">
-        <p className="page-meta">
+        <div className="page-meta">
           <AuthorInfo user={creator} date={createdAt} mode="create" locate="footer" />
           <AuthorInfo user={revisionAuthor} date={updatedAt} mode="update" locate="footer" />
-        </p>
+        </div>
       </div>
     </div>
   );

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

@@ -185,7 +185,7 @@ class SearchResult extends React.Component {
       const pageId = `#id_${page._id}`;
       return (
         <li key={page._id} className="nav-item page-list-li w-100 m-1">
-          <a className="nav-link page-list-link d-flex align-items-center" href={pageId}>
+          <a className="nav-link page-list-link d-flex align-items-baseline" href={pageId}>
             <Page page={page} noLink />
             <div className="ml-auto d-flex">
               { this.state.deletionMode

+ 1 - 1
src/client/js/components/ShareLink/ShareLinkForm.jsx

@@ -22,7 +22,7 @@ class ShareLinkForm extends React.Component {
       numberOfDays: '7',
       description: '',
       customExpirationDate: dateFnsFormat(new Date(), 'yyyy-MM-dd'),
-      customExpirationTime: dateFnsFormat(new Date(), 'hh:mm'),
+      customExpirationTime: dateFnsFormat(new Date(), 'HH:mm'),
     };
 
     this.handleChangeExpirationType = this.handleChangeExpirationType.bind(this);

+ 21 - 9
src/client/js/components/TableOfContents.jsx

@@ -20,7 +20,7 @@ const logger = loggerFactory('growi:TableOfContents');
  */
 const TableOfContents = (props) => {
 
-  const { pageContainer, navigationContainer } = props;
+  const { t, pageContainer, navigationContainer } = props;
   const { pageUser } = pageContainer.state;
   const isUserPage = pageUser != null;
 
@@ -59,14 +59,24 @@ const TableOfContents = (props) => {
       stickyElemSelector=".grw-side-contents-sticky-container"
       calcViewHeightFunc={calcViewHeight}
     >
-      <div
-        id="revision-toc-content"
-        className="revision-toc-content"
-        // eslint-disable-next-line react/no-danger
-        dangerouslySetInnerHTML={{
-        __html: tocHtml,
-      }}
-      />
+      { tocHtml !== ''
+      ? (
+        <div
+          id="revision-toc-content"
+          className="revision-toc-content mb-3"
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{ __html: tocHtml }}
+        />
+      )
+      : (
+        <div
+          id="revision-toc-content"
+          className="revision-toc-content mb-2"
+        >
+          <span className="text-muted">({t('page_table_of_contents.empty')})</span>
+        </div>
+      ) }
+
     </StickyStretchableScroller>
   );
 
@@ -78,6 +88,8 @@ const TableOfContents = (props) => {
 const TableOfContentsWrapper = withUnstatedContainers(TableOfContents, [PageContainer, NavigationContainer]);
 
 TableOfContents.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };

+ 11 - 9
src/client/js/components/TrashPageList.jsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useMemo } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import PageListIcon from './Icons/PageListIcon';
@@ -9,14 +9,16 @@ import PageList from './PageList';
 const TrashPageList = (props) => {
   const { t } = props;
 
-  const navTabMapping = {
-    pagelist: {
-      Icon: PageListIcon,
-      Content: PageList,
-      i18n: t('page_list'),
-      index: 0,
-    },
-  };
+  const navTabMapping = useMemo(() => {
+    return {
+      pagelist: {
+        Icon: PageListIcon,
+        Content: PageList,
+        i18n: t('page_list'),
+        index: 0,
+      },
+    };
+  }, [t]);
 
   return (
     <div className="mt-5 d-edit-none">

+ 1 - 1
src/client/js/components/User/UserInfo.jsx

@@ -12,7 +12,7 @@ const UserInfo = (props) => {
   }
 
   return (
-    <div className="grw-users-info d-flex align-items-center d-edit-none pb-2 border-bottom">
+    <div className="grw-users-info d-flex align-items-center d-edit-none mb-5 pb-3 border-bottom">
       <UserPicture user={pageUser} />
 
       <div className="users-meta">

+ 3 - 46
src/client/js/legacy/crowi.js

@@ -154,59 +154,16 @@ Crowi.highlightSelectedSection = function(hash) {
   }
 };
 
-$(() => {
-  const pageId = $('#content-main').data('page-id');
-  // const revisionId = $('#content-main').data('page-revision-id');
-  // const revisionCreatedAt = $('#content-main').data('page-revision-created');
-  // const currentUser = $('#content-main').data('current-user');
-  const isSeen = $('#content-main').data('page-is-seen');
-
-  $('[data-toggle="popover"]').popover();
-  $('[data-toggle="tooltip"]').tooltip();
-  $('[data-tooltip-stay]').tooltip('show');
-
-  $('#toggle-crowi-sidebar').click((e) => {
-    const $body = $('body');
-    if ($body.hasClass('aside-hidden')) {
-      $body.removeClass('aside-hidden');
-      $.cookie('aside-hidden', 0, { expires: 30, path: '/' });
-    }
-    else {
-      $body.addClass('aside-hidden');
-      $.cookie('aside-hidden', 1, { expires: 30, path: '/' });
-    }
-    return false;
-  });
-
-  if ($.cookie('aside-hidden') === 1) {
-    $('body').addClass('aside-hidden');
-  }
-
-  $('.copy-link').on('click', function() {
-    $(this).select();
-  });
-
-  if (pageId) {
-
-    if (!isSeen) {
-      $.post('/_api/pages.seen', { page_id: pageId }, (res) => {
-        // ignore unless response has error
-        if (res.ok && res.seenUser) {
-          $('#content-main').data('page-is-seen', 1);
-        }
-      });
-    }
-  } // end if pageId
-});
-
 window.addEventListener('load', (e) => {
   const { appContainer } = window;
+  const pageContainer = appContainer.getContainer('PageContainer');
+  const { isEditable } = pageContainer;
 
   // hash on page
   if (window.location.hash) {
     const navigationContainer = appContainer.getContainer('NavigationContainer');
 
-    if (window.location.hash === '#edit') {
+    if (window.location.hash === '#edit' && isEditable) {
       navigationContainer.setEditorMode('edit');
 
       // focus

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

@@ -176,6 +176,10 @@ export default class NavigationContainer extends Container {
   }
 
   openPageCreateModal() {
+    if (this.appContainer.currentUser == null) {
+      logger.warn('Please login or signup to create a new page.');
+      return;
+    }
     this.setState({ isPageCreateModalShown: true });
   }
 

+ 34 - 22
src/client/js/services/PageContainer.js

@@ -101,9 +101,15 @@ export default class PageContainer extends Container {
     interceptorManager.addInterceptor(new DrawioInterceptor(appContainer), 20);
     interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(appContainer), 900); // process as late as possible
 
-    this.retrieveSeenUsers();
     this.initStateMarkdown();
-    this.initStateOthers();
+    this.checkAndUpdateImageUrlCached(this.state.likerUsers);
+
+    // skip if shared page or new page
+    if (this.state.shareLinkId == null && this.state.pageId != null) {
+      this.retrieveSeenUsers();
+      this.retrieveLikeInfo();
+      this.retrieveBookmarkInfo();
+    }
 
     this.setTocHtml = this.setTocHtml.bind(this);
     this.save = this.save.bind(this);
@@ -133,6 +139,19 @@ export default class PageContainer extends Container {
     return 'PageContainer';
   }
 
+
+  get isEditable() {
+    const { currentUser } = this.appContainer;
+    const {
+      isPageExist, isPageForbidden, isNotCreatable, isTrashPage,
+    } = this.state;
+
+    if (isPageExist && (currentUser != null) && !isPageForbidden && !isNotCreatable && !isTrashPage) {
+      return true;
+    }
+    return false;
+  }
+
   /**
    * initialize state for markdown data
    */
@@ -155,19 +174,13 @@ export default class PageContainer extends Container {
     this.checkAndUpdateImageUrlCached(users);
   }
 
-  async initStateOthers() {
-
-    this.retrieveLikeInfo();
-    this.retrieveBookmarkInfo();
-    this.checkAndUpdateImageUrlCached(this.state.likerUsers);
-  }
-
   async retrieveLikeInfo() {
-    const like = await this.appContainer.apiv3Get('/page/like-info', { _id: this.state.pageId });
+    const res = await this.appContainer.apiv3Get('/page/like-info', { _id: this.state.pageId });
+    const { sumOfLikers, isLiked } = res.data;
+
     this.setState({
-      sumOfLikers: like.data.sumOfLikers,
-      likerUsers: like.data.users.liker,
-      isLiked: like.data.isLiked,
+      sumOfLikers,
+      isLiked,
     });
   }
 
@@ -180,14 +193,11 @@ export default class PageContainer extends Container {
   }
 
   async retrieveBookmarkInfo() {
-    const response = await this.appContainer.apiv3Get('/bookmarks', { pageId: this.state.pageId });
-    if (response.data.bookmarks != null) {
-      this.setState({ isBookmarked: true });
-    }
-    else {
-      this.setState({ isBookmarked: false });
-    }
-    this.setState({ sumOfBookmarks: response.data.sumOfBookmarks });
+    const response = await this.appContainer.apiv3Get('/bookmarks/info', { pageId: this.state.pageId });
+    this.setState({
+      sumOfBookmarks: response.data.sumOfBookmarks,
+      isBookmarked: response.data.isBookmarked,
+    });
   }
 
   async toggleBookmark() {
@@ -247,6 +257,8 @@ export default class PageContainer extends Container {
       revisionIdHackmdSynced: page.revisionHackmdSynced,
       hasDraftOnHackmd: page.hasDraftOnHackmd,
       markdown: page.revision.body,
+      createdAt: page.createdAt,
+      updatedAt: page.updatedAt,
     };
     if (tags != null) {
       newState.tags = tags;
@@ -256,7 +268,7 @@ export default class PageContainer extends Container {
     // PageEditor component
     const pageEditor = this.appContainer.getComponentInstance('PageEditor');
     if (pageEditor != null) {
-      if (editorMode !== 'builtin') {
+      if (editorMode !== 'edit') {
         pageEditor.updateEditorValue(newState.markdown);
       }
     }

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

@@ -39,11 +39,15 @@ export default class TagContainer extends Container {
       return;
     }
 
-    const { pageId, templateTagData } = pageContainer.state;
+    const { pageId, templateTagData, shareLinkId } = pageContainer.state;
+
+    if (shareLinkId != null) {
+      return;
+    }
 
     let tags = [];
-    // when the page exists
-    if (pageId != null) {
+    // when the page exists or shared page
+    if (pageId != null && shareLinkId == null) {
       const res = await this.appContainer.apiGet('/pages.getPageTag', { pageId });
       tags = res.tags;
     }

+ 4 - 10
src/client/styles/scss/_layout.scss

@@ -36,6 +36,10 @@ body {
   }
 }
 
+.grw-side-contents-container {
+  margin-left: 30px;
+}
+
 .grw-side-contents-sticky-container {
   position: sticky;
   // growisubnavigation + grw-navbar-boder
@@ -103,16 +107,6 @@ body {
       margin-bottom: 20px;
       font-size: 0.9em;
       border: solid 1px $gray-400;
-
-      .revision-toc-head {
-        display: inline-block;
-        float: none;
-      }
-
-      .revision-toc-content.collapse {
-        display: block;
-        height: auto;
-      }
     }
 
     .meta {

+ 1 - 2
src/client/styles/scss/_page-accessories-control.scss

@@ -4,8 +4,7 @@
   border-bottom: 1px solid transparent;
 
   .grw-btn-page-accessories {
-    padding: 0.375rem 0.5rem;
-    margin: 0 0.2rem;
+    padding: 0.375rem;
 
     svg {
       width: 16px;

+ 1 - 26
src/client/styles/scss/_page.scss

@@ -1,32 +1,7 @@
 // import diff2html styles
 @import '~diff2html/bundles/css/diff2html.min.css';
 
-.main-container {
-  .url-line {
-    font-size: 1rem;
-    color: $gray-400;
-  }
-
-  h1.title {
-    margin-top: 0;
-    margin-bottom: 0;
-
-    .d-flex {
-      flex-wrap: wrap; // for long page path
-    }
-
-    // crowi layout only
-    a.last-path {
-      color: $gray-300;
-
-      &:hover {
-        color: inherit;
-      }
-    }
-  }
-}
-
-.main .content-main .revision-history {
+.revision-history {
   .revision-history-list {
     .revision-history-outer {
       // add border-top except of first element

+ 0 - 10
src/client/styles/scss/_page_list.scss

@@ -72,13 +72,3 @@ body .page-list {
     background-color: $gray-300;
   }
 }
-
-.grw-page-list-m {
-  .grw-page-list-title-m {
-    svg {
-      width: 35px;
-      height: 35px;
-      margin-bottom: 6px;
-    }
-  }
-}

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

@@ -164,6 +164,7 @@
       > li {
         > a {
           padding: 2px 8px;
+          word-break: break-all;
           border-radius: 0;
 
           &:hover {

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

@@ -106,7 +106,7 @@ $easeInOutCubic: cubic-bezier(0.65, 0, 0.35, 1);
   z-index: $zindex-sticky - 5;
 
   .grw-subnav {
-    box-shadow: 0px 6px 6px 3px rgba(black, 0.15);
+    box-shadow: 0px 0px 6px 3px rgba(black, 0.15);
   }
 }
 

+ 10 - 0
src/client/styles/scss/_user.scss

@@ -43,3 +43,13 @@ $easeInOutCubic: cubic-bezier(0.65, 0, 0.35, 1);
     }
   }
 }
+
+.user-page {
+  .grw-user-page-header {
+    svg {
+      width: 35px;
+      height: 35px;
+      margin-bottom: 6px;
+    }
+  }
+}

+ 0 - 6
src/client/styles/scss/_user_growi.scss

@@ -1,6 +0,0 @@
-.growi .user-page {
-  .revision-toc {
-    position: sticky;
-    top: 105px;
-  }
-}

+ 10 - 1
src/client/styles/scss/atoms/_buttons.scss

@@ -1,5 +1,5 @@
 .btn.btn-like {
-  @include button-outline-variant($secondary, lighten($info, 15%), rgba(lighten($info, 10%), 0.5), rgba(lighten($info, 10%), 0.5));
+  @include button-outline-variant($secondary, lighten($info, 15%), rgba(lighten($info, 10%), 0.15), rgba(lighten($info, 10%), 0.5));
   &:not(:disabled):not(.disabled):active,
   &:not(:disabled):not(.disabled).active {
     color: lighten($info, 15%);
@@ -91,3 +91,12 @@
   background-color: transparent;
   transition: 0.3s;
 }
+
+// define disabled button w/o pointer-events, see _override-bootstrap.scss
+.btn.disabled,
+.btn[disabled],
+fieldset[disabled] .btn {
+  &.grw-pointer-events-none {
+    pointer-events: none;
+  }
+}

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

@@ -57,7 +57,6 @@
 @import 'tag';
 @import 'toc';
 @import 'user';
-@import 'user_growi';
 @import 'staff_credit';
 @import 'waves';
 @import 'wiki';

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

@@ -341,10 +341,8 @@ ul.pagination {
   }
 }
 
-.growi .main {
-  .page-comments-row {
-    background: $bgcolor-subnav;
-  }
+.page-comments-row {
+  background: $bgcolor-subnav;
 }
 
 /*

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

@@ -281,10 +281,8 @@ $table-hover-bg: $bgcolor-table-hover;
   }
 }
 
-.growi .main {
-  .page-comments-row {
-    background: $bgcolor-subnav;
-  }
+.page-comments-row {
+  background: $bgcolor-subnav;
 }
 
 /*

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

@@ -137,7 +137,8 @@ ul.pagination {
     button.page-link {
       color: color-yiq($primary);
       background-color: $primary;
-      &:hover {
+      &:hover,
+      &:focus {
         color: color-yiq($primary);
         background-color: $primary;
       }
@@ -452,7 +453,7 @@ body.on-edit {
 /*
  * GROWI comment form
  */
-.growi .main {
+.page-comments {
   .page-comment .page-comment-main,
   .page-comment-form .comment-form-main {
     background-color: $bgcolor-global;

+ 1 - 1
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: $gray-50;
+  $bgcolor-card: $bgcolor-global;
 
   // Font colors
   $color-global: #e9af2b;

+ 4 - 0
src/client/styles/scss/theme/spring.scss

@@ -146,6 +146,10 @@ html[dark] {
   h1,
   h2 {
     color: $subthemecolor;
+
+    svg {
+      fill: $subthemecolor;
+    }
   }
 
   .nav.nav-tabs {

+ 2 - 2
src/server/middlewares/login-required.js

@@ -47,13 +47,13 @@ module.exports = (crowi, isGuestAllowed = false, fallback = null) => {
     const path = req.path || '';
     if (path.match(/^\/_api\/.+$/)) {
       if (fallback != null) {
-        return fallback(req, res);
+        return fallback(req, res, next);
       }
       return res.sendStatus(403);
     }
 
     if (fallback != null) {
-      return fallback(req, res);
+      return fallback(req, res, next);
     }
     req.session.redirectTo = req.originalUrl;
     return res.redirect('/login');

+ 43 - 36
src/server/routes/apiv3/bookmarks.js

@@ -50,11 +50,23 @@ const router = express.Router();
  *          bool:
  *            type: boolean
  *            description: boolean for bookmark status
+ *
+ *      BookmarkInfo:
+ *        description: BookmarkInfo
+ *        type: object
+ *        properties:
+ *          sumOfBookmarks:
+ *            type: number
+ *            description: how many people bookmarked the page
+ *          isBookmarked:
+ *            type: boolean
+ *            description: Whether the request user bookmarked (will be returned if the user is included in the request)
  */
 
 module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
-  const loginRequired = require('../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('@server/middlewares/login-required')(crowi);
+  const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const csrf = require('../../middlewares/csrf')(crowi);
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
@@ -73,12 +85,12 @@ module.exports = (crowi) => {
   /**
    * @swagger
    *
-   *    /bookmarks:
+   *    /bookmarks/info:
    *      get:
    *        tags: [Bookmarks]
-   *        summary: /bookmarks
-   *        description: Get bookmarked status
-   *        operationId: getBookmarkedStatus
+   *        summary: /bookmarks/info
+   *        description: Get bookmarked info
+   *        operationId: getBookmarkedInfo
    *        parameters:
    *          - name: pageId
    *            in: query
@@ -87,24 +99,41 @@ module.exports = (crowi) => {
    *              type: string
    *        responses:
    *          200:
-   *            description: Succeeded to get bookmarked status.
+   *            description: Succeeded to get bookmark info.
    *            content:
    *              application/json:
    *                schema:
-   *                  $ref: '#/components/schemas/Bookmark'
+   *                  $ref: '#/components/schemas/BookmarkInfo'
    */
-  router.get('/', accessTokenParser, loginRequired, validator.bookmarkInfo, async(req, res) => {
+  router.get('/info', accessTokenParser, loginRequired, validator.bookmarkInfo, apiV3FormValidator, async(req, res) => {
+    const { user } = req;
     const { pageId } = req.query;
 
+    const responsesParams = {};
+
     try {
-      const bookmarks = await Bookmark.findByPageIdAndUserId(pageId, req.user);
-      const sumOfBookmarks = await Bookmark.countByPageId(pageId);
-      return res.apiv3({ bookmarks, sumOfBookmarks });
+      responsesParams.sumOfBookmarks = await Bookmark.countByPageId(pageId);
     }
     catch (err) {
-      logger.error('get-bookmark-failed', err);
+      logger.error('get-bookmark-count-failed', err);
       return res.apiv3Err(err, 500);
     }
+
+    // guest user only get bookmark count
+    if (user == null) {
+      return res.apiv3(responsesParams);
+    }
+
+    try {
+      const bookmark = await Bookmark.findByPageIdAndUserId(pageId, user._id);
+      responsesParams.isBookmarked = (bookmark != null);
+      return res.apiv3(responsesParams);
+    }
+    catch (err) {
+      logger.error('get-bookmark-state-failed', err);
+      return res.apiv3Err(err, 500);
+    }
+
   });
 
   // select page from bookmark where userid = userid
@@ -152,7 +181,7 @@ module.exports = (crowi) => {
     query('limit').if(value => value != null).isInt({ max: 300 }).withMessage('You should set less than 300 or not to set limit.'),
   ];
 
-  router.get('/:userId', accessTokenParser, loginRequired, validator.myBookmarkList, apiV3FormValidator, async(req, res) => {
+  router.get('/:userId', accessTokenParser, loginRequiredStrictly, validator.myBookmarkList, apiV3FormValidator, async(req, res) => {
     const { userId } = req.params;
     const page = req.query.page;
     const limit = parseInt(req.query.limit) || await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationM') || 30;
@@ -213,7 +242,7 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/Bookmark'
    */
-  router.put('/', accessTokenParser, loginRequired, csrf, validator.bookmarks, apiV3FormValidator, async(req, res) => {
+  router.put('/', accessTokenParser, loginRequiredStrictly, csrf, validator.bookmarks, apiV3FormValidator, async(req, res) => {
     const { pageId, bool } = req.body;
 
     let bookmark;
@@ -240,27 +269,5 @@ module.exports = (crowi) => {
     return res.apiv3({ bookmark });
   });
 
-  /**
-   * @swagger
-   *
-   *    /count-bookmarks:
-   *      get:
-   *        tags: [Bookmarks]
-   *        summary: /bookmarks
-   *        description: Count bookmsrks
-   *        requestBody:
-   *          content:
-   *            application/json:
-   *              schema:
-   *                $ref: '#/components/schemas/BookmarkParams'
-   *        responses:
-   *          200:
-   *            description: Succeeded to count bookmarks.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  $ref: '#/components/schemas/Bookmark'
-   */
-
   return router;
 };

+ 49 - 8
src/server/routes/apiv3/page.js

@@ -9,6 +9,7 @@ const router = express.Router();
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
+
 /**
  * @swagger
  *  tags:
@@ -109,6 +110,17 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *          bool:
  *            type: boolean
  *            description: boolean for like status
+ *
+ *      LikeInfo:
+ *        description: LikeInfo
+ *        type: object
+ *        properties:
+ *          sumOfLikers:
+ *            type: number
+ *            description: how many people liked the page
+ *          isLiked:
+ *            type: boolean
+ *            description: Whether the request user liked (will be returned if the user is included in the request)
  */
 module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
@@ -118,7 +130,7 @@ module.exports = (crowi) => {
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const globalNotificationService = crowi.getGlobalNotificationService();
-  const { Page, GlobalNotificationSetting, User } = crowi.models;
+  const { Page, GlobalNotificationSetting } = crowi.models;
   const { exportService } = crowi;
 
   const validator = {
@@ -200,19 +212,48 @@ module.exports = (crowi) => {
     return res.apiv3({ result });
   });
 
-  router.get('/like-info', loginRequired, validator.likeInfo, async(req, res) => {
+  /**
+   * @swagger
+   *
+   *    /page/like-info:
+   *      get:
+   *        tags: [Page]
+   *        summary: /page/like-info
+   *        description: Get like info
+   *        operationId: getLikeInfo
+   *        parameters:
+   *          - name: _id
+   *            in: query
+   *            description: page id
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: Succeeded to get bookmark info.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/LikeInfo'
+   */
+  router.get('/like-info', loginRequired, validator.likeInfo, apiV3FormValidator, async(req, res) => {
     const pageId = req.query._id;
-    const userId = req.user._id;
+
+    const responsesParams = {};
+
     try {
       const page = await Page.findById(pageId);
-      const users = await Page.findById(pageId).populate('liker', User.USER_PUBLIC_FIELDS);
-      const sumOfLikers = page.liker.length;
-      const isLiked = page.liker.includes(userId);
+      responsesParams.sumOfLikers = page.liker.length;
+
+      // guest user return nothing
+      if (!req.user) {
+        return res.apiv3(responsesParams);
+      }
 
-      return res.apiv3({ users, sumOfLikers, isLiked });
+      responsesParams.isLiked = page.liker.includes(req.user._id);
+      return res.apiv3(responsesParams);
     }
     catch (err) {
-      logger.error('error like info', err);
+      logger.error('get-like-count-failed', err);
       return res.apiv3Err(err, 500);
     }
   });

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

@@ -143,7 +143,6 @@ module.exports = function(crowi, app) {
   app.get('/_api/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
   app.get('/_api/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
   // allow posting to guests because the client doesn't know whether the user logged in
-  app.post('/_api/pages.seen'         , accessTokenParser , loginRequired , page.api.seen);
   app.post('/_api/pages.rename'       , accessTokenParser , loginRequiredStrictly , csrf, page.api.rename);
   app.post('/_api/pages.remove'       , loginRequiredStrictly , csrf, page.api.remove); // (Avoid from API Token)
   app.post('/_api/pages.revertRemove' , loginRequiredStrictly , csrf, page.api.revertRemove); // (Avoid from API Token)

+ 10 - 76
src/server/routes/page.js

@@ -65,13 +65,6 @@
  *            example: ""
  *          revision:
  *            $ref: '#/components/schemas/Revision'
- *          seenUsers:
- *            type: array
- *            description: granted users
- *            items:
- *              type: string
- *              description: user ID
- *            example: ["5ae5fccfc5577b0004dbd8ab"]
  *          status:
  *            type: string
  *            description: status
@@ -327,6 +320,11 @@ module.exports = function(crowi, app) {
     let portalPage = await Page.findByPathAndViewer(portalPath, req.user);
     portalPage.initLatestRevisionField(revisionId);
 
+    // add user to seen users
+    if (req.user != null) {
+      portalPage = await portalPage.seen(req.user);
+    }
+
     // populate
     portalPage = await portalPage.populateDataToShowRevision();
 
@@ -372,6 +370,11 @@ module.exports = function(crowi, app) {
 
     page.initLatestRevisionField(revisionId);
 
+    // add user to seen users
+    if (req.user != null) {
+      page = await page.seen(req.user);
+    }
+
     // populate
     page = await page.populateDataToShowRevision();
     addRenderVarsForPage(renderVars, page);
@@ -1116,75 +1119,6 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success(result));
   };
 
-  /**
-   * @swagger
-   *
-   *    /pages.seen:
-   *      post:
-   *        tags: [Pages, CrowiCompatibles]
-   *        operationId: seenPage
-   *        summary: /pages.seen
-   *        description: Mark as seen user
-   *        requestBody:
-   *          content:
-   *            application/json:
-   *              schema:
-   *                properties:
-   *                  page_id:
-   *                    $ref: '#/components/schemas/Page/properties/_id'
-   *                required:
-   *                  - page_id
-   *        responses:
-   *          200:
-   *            description: Succeeded to be page seen.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    seenUser:
-   *                      $ref: '#/components/schemas/Page/properties/seenUsers'
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {post} /pages.seen Mark as seen user
-   * @apiName SeenPage
-   * @apiGroup Page
-   *
-   * @apiParam {String} page_id Page Id.
-   */
-  api.seen = async function(req, res) {
-    const user = req.user;
-    const pageId = req.body.page_id;
-    if (!pageId) {
-      return res.json(ApiResponse.error('page_id required'));
-    }
-    if (!req.user) {
-      return res.json(ApiResponse.error('user required'));
-    }
-
-    let page;
-    try {
-      page = await Page.findByIdAndViewer(pageId, user);
-      if (user != null) {
-        page = await page.seen(user);
-      }
-    }
-    catch (err) {
-      debug('Seen user update error', err);
-      return res.json(ApiResponse.error(err));
-    }
-
-    const result = {};
-    result.seenUser = page.seenUsers;
-
-    return res.json(ApiResponse.success(result));
-  };
-
   /**
    * @swagger
    *

+ 0 - 3
src/server/views/admin/app.html

@@ -13,6 +13,3 @@
 {% block content_main %}
   <div id="admin-app"></div>
 {% endblock content_main %}
-
-{% block content_footer %}
-{% endblock content_footer %}

+ 0 - 2
src/server/views/admin/customize.html

@@ -22,5 +22,3 @@
 </div>
 <div id="admin-customize" class="admin-customize"></div>
 {% endblock content_main %}
-
-{% block content_footer %} {% endblock content_footer %}

+ 0 - 3
src/server/views/admin/export.html

@@ -9,6 +9,3 @@
 {% block content_main %}
 <div id="admin-export-page" class="admin-export"></div>
 {% endblock content_main %}
-
-{% block content_footer %}
-{% endblock content_footer %}

+ 0 - 5
src/server/views/admin/external-accounts.html

@@ -9,8 +9,3 @@
 {% block content_main %}
 <div id="admin-external-account-setting"></div>
 {% endblock content_main %}
-
-{% block content_footer %}
-{% endblock content_footer %}
-
-

+ 0 - 3
src/server/views/admin/global-notification-detail.html

@@ -10,6 +10,3 @@
 <div id="admin-global-notification-setting"
     data-global-notification="{{ globalNotification|json }}"></div>
 {% endblock content_main %}
-
-{% block content_footer %}
-{% endblock content_footer %}

+ 0 - 3
src/server/views/admin/importer.html

@@ -9,6 +9,3 @@
 {% block content_main %}
 <div id="admin-importer" class="admin-importer"></div>
 {% endblock content_main %}
-
-{% block content_footer %}
-{% endblock content_footer %}

+ 0 - 3
src/server/views/admin/index.html

@@ -9,6 +9,3 @@
 {% block content_main %}
 <div id="admin-home"></div>
 {% endblock content_main %}
-
-{% block content_footer %}
-{% endblock content_footer %}

+ 0 - 10
src/server/views/admin/markdown.html

@@ -9,13 +9,3 @@
 {% block content_main %}
 <div id="admin-markdown-setting"></div>
 {% endblock content_main %}
-
-{% block content_footer %}
-{% endblock content_footer %}
-
-
-
-
-
-
-

+ 0 - 3
src/server/views/admin/notification.html

@@ -9,6 +9,3 @@
 {% block content_main %}
 <div id="admin-notification-setting" class="admin-notification"></div>
 {% endblock content_main %}
-
-{% block content_footer %}
-{% endblock content_footer %}

+ 0 - 3
src/server/views/admin/search.html

@@ -9,6 +9,3 @@
 {% block content_main %}
   <div id ="admin-full-text-search-management"></div>
 {% endblock content_main %}
-
-{% block content_footer %}
-{% endblock content_footer %}

+ 0 - 3
src/server/views/admin/security.html

@@ -9,6 +9,3 @@
 {% block content_main %}
 <div id="admin-security-setting" class="admin-security"></div>
 {% endblock content_main %}
-
-{% block content_footer %}
-{% endblock content_footer %}

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

@@ -13,6 +13,3 @@
 >
 </div>
 {% endblock content_main %}
-
-{% block content_footer %}
-{% endblock content_footer %}

+ 0 - 5
src/server/views/admin/user-groups.html

@@ -9,8 +9,3 @@
 {% block content_main %}
 <div id ="admin-user-group-page"></div>
 {% endblock content_main %}
-
-{% block content_footer %}
-{% endblock content_footer %}
-
-

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

@@ -9,8 +9,3 @@
 {% block content_main %}
 <div id ="admin-user-page"></div>
 {% endblock content_main %}
-
-{% block content_footer %}
-{% endblock content_footer %}
-
-

+ 25 - 19
src/server/views/layout-growi/base/layout.html

@@ -7,29 +7,35 @@
 {% endblock %}
 
 {% block layout_main %}
+<div class="h-100 d-flex flex-column justify-content-between">
 
-{% block content_header_wrapper %}
-<header class="py-0">
-  {% block content_header %}
-    <div id="grw-subnav-container"></div>
+  {% block content_header_wrapper %}
+    <header class="py-0">
+      {% block content_header %}
+        <div id="grw-subnav-container"></div>
+      {% endblock %}
+    </header>
+    <div id="grw-subnav-switcher-container" class="d-edit-none"></div>
+    <div id="grw-subnav-sticky-trigger" class="sticky-top"></div>
+    <div id="grw-fav-sticky-trigger" class="sticky-top"></div>
   {% endblock %}
-</header>
-<div id="grw-subnav-switcher-container" class="d-edit-none"></div>
-<div id="grw-subnav-sticky-trigger" class="sticky-top"></div>
-<div id="grw-fav-sticky-trigger" class="sticky-top"></div>
-{% endblock %}
 
-<div id="main" class="main {% if page %}{{ css.grant(page) }}{% endif %} {% block main_css_class %}{% endblock %}">
-  {% block content_main_before %}
-  {% endblock %}
+  <div class="flex-grow-1">
+    <div id="main" class="main {% if page %}{{ css.grant(page) }}{% endif %}">
+      {% block content_main_before %}
+      {% endblock %}
 
-  {% block content_main %}
-  {% endblock content_main %}
+      {% block content_main %}
+      {% endblock content_main %}
 
-  {% block content_main_after %}
-  {% endblock %}
-</div><!-- /.main -->
+      {% block content_main_after %}
+      {% endblock %}
+    </div>
+  </div>
+
+  <footer class="footer">
+    {% block content_footer %}{% endblock %}
+  </footer>
 
-<footer class="footer">
-</footer>
+</div>
 {% endblock %} {# layout_main #}

+ 1 - 5
src/server/views/layout-growi/forbidden.html

@@ -9,11 +9,7 @@
 
 {% block content_main %}
   <div class="container-lg">
-    <div class="row">
-      <div class="col">
-        {% include '../widget/forbidden_content.html' %}
-      </div>
-    </div>
+    {% include '../widget/forbidden_content.html' %}
   </div>
 {% endblock %}
 

+ 1 - 5
src/server/views/layout-growi/not_creatable.html

@@ -10,11 +10,7 @@
 
 {% block content_main %}
   <div class="container-lg">
-    <div class="row">
-      <div class="col">
-        {% include '../widget/not_creatable_content.html' %}
-      </div>
-    </div>
+    {% include '../widget/not_creatable_content.html' %}
   </div>
 {% endblock %}
 

+ 1 - 5
src/server/views/layout-growi/not_found.html

@@ -10,11 +10,7 @@
 
 {% block content_main %}
   <div class="container-lg">
-    <div class="row">
-      <div class="col">
-        {% include '../widget/not_found_content.html' %}
-      </div>
-    </div>
+    {% include '../widget/not_found_content.html' %}
   </div>
 {% endblock %}
 

+ 1 - 3
src/server/views/layout-growi/page.html

@@ -13,13 +13,11 @@
   </div>
 {% endblock %}
 
-
-{% block content_main_after %}
+{% block content_footer %}
   {% include 'widget/comments.html' %}
   <div id="page-content-footer"></div>
 {% endblock %}
 
-
 {% block body_end %}
   <div id="presentation-layer" class="fullscreen-layer">
     <div id="presentation-container"></div>

+ 3 - 1
src/server/views/layout-growi/page_list.html

@@ -18,9 +18,11 @@
       <div id="trash-page-list"></div>
     </div>
   {% endif %}
-  <div id="page-content-footer"></div>
 {% endblock %}
 
+{% block content_footer %}
+  <div id="page-content-footer"></div>
+{% endblock %}
 
 {% block body_end %}
   <div id="presentation-layer" class="fullscreen-layer">

+ 4 - 11
src/server/views/layout-growi/user_page.html

@@ -1,27 +1,21 @@
 {% extends 'page.html' %}
 
-{% block main_css_class %}
-  {% parent %}
-  user-page
-{% endblock %}
-
 {% block content_main %}
-  <div class="container-lg">
+  <div class="container-lg user-page">
 
     {% include '../widget/page_content.html' %}
 
   </div>
 {% endblock %}
 
-
-{% block content_main_after %}
+{% block content_footer %}
   {% include 'widget/comments.html' %}
 
   {% if page %}
     <div class="container-lg">
 
       <div class="grw-page-list-m mt-5 pb-5 d-edit-none">
-        <h2 class="grw-page-list-title-m border-bottom pb-2 mb-3" id="bookmarks-list">
+        <h2 class="grw-user-page-header border-bottom pb-2 mb-3" id="bookmarks-list">
           <i id="user-bookmark-icon"></i>
           Bookmarks
         </h2>
@@ -32,7 +26,7 @@
       </div>
 
       <div class="grw-page-list-m mt-5 pb-5 d-edit-none">
-        <h2 class="grw-page-list-title-m border-bottom pb-2 mb-3" id="recently-created-list">
+        <h2 class="grw-user-page-header border-bottom pb-2 mb-3" id="recently-created-list">
           <i id="recent-created-icon"></i>
           Recently Created
         </h2>
@@ -46,5 +40,4 @@
   {% endif %}
 
   <div id="page-content-footer"></div>
-
 {% endblock %}

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

@@ -16,7 +16,7 @@
 </header>
 {% endblock %}
 
-<div id="main" class="main {% block main_css_class %}{% endblock %}">
+<div id="main" class="main">
 
   <div class="container-fluid">
     <div class="row">
@@ -34,7 +34,4 @@
     </div>
   </div>
 </div><!-- /.main -->
-
-<footer class="footer">
-</footer>
 {% endblock %} {# layout_main #}

+ 15 - 12
src/server/views/me/drafts.html

@@ -1,18 +1,21 @@
-{% extends '../layout-growi/base/layout.html' %}
+{% extends '../layout/layout.html' %}
 
 {% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('My Drafts')) }}{% endblock %}
 
-{% block content_header %}
+{% block layout_main %}
+
+{% block content_header_wrapper %}
+<header class="py-3">
+  <div class="container-fluid">
+    <h1 class="title">{{ t('My Drafts') }}</h1>
+  </div>
+</header>
+<div id="grw-fav-sticky-trigger" class="sticky-top"></div>
 {% endblock %}
 
-{% block content_main %}
-<div id="content-main" class="content-main container">
-  <div id="my-drafts"></div>
+<div id="main" class="main">
+  <div id="content-main" class="content-main container-lg">
+    <div id="my-drafts"></div>
+  </div>
 </div>
-{% endblock content_main %}
-
-{% block content_footer %}
-{% endblock content_footer %}
-
-{% block layout_footer %}
-{% endblock layout_footer %}
+{% endblock %}

+ 15 - 13
src/server/views/me/index.html

@@ -1,19 +1,21 @@
-{% extends '../layout-growi/base/layout.html' %}
+{% extends '../layout/layout.html' %}
 
 {% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('User Settings')) }}{% endblock %}
 
-{% block html_base_css %}user-settings-page{% endblock %}
+{% block layout_main %}
 
-{% block content_header %}
-<h1 class="title">{{ t('User Settings') }}</h1>
+{% block content_header_wrapper %}
+<header class="py-3">
+  <div class="container-fluid">
+    <h1 class="title">{{ t('User Settings') }}</h1>
+  </div>
+</header>
+<div id="grw-fav-sticky-trigger" class="sticky-top"></div>
 {% endblock %}
 
-{% block content_main %}
-<div class="content-main" id="personal-setting"></div>
-{% endblock content_main %}
-
-{% block content_footer %}
-{% endblock content_footer %}
-
-{% block layout_footer %}
-{% endblock layout_footer %}
+<div id="main" class="main">
+  <div id="content-main" class="content-main container-lg">
+    <div class="content-main" id="personal-setting"></div>
+  </div>
+</div>
+{% endblock %}

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

@@ -23,7 +23,4 @@
   </div>
 
 </div><!-- /.container-fluid -->
-
-<footer class="footer">
-</footer>
 {% endblock %} {# layout_main #}

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

@@ -16,7 +16,4 @@
     </div>
   </div>
 </div><!-- /.container-fluid -->
-
-<footer class="footer">
-</footer>
 {% endblock %} {# layout_main #}

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

@@ -1,4 +1,4 @@
-<div class="row not-found-message-row mb-4">
+<div class="row not-found-message-row">
   <div class="col-md-12">
     <h2 class="text-muted">
       <i class="icon-ban" aria-hidden="true"></i>

+ 5 - 4
src/server/views/widget/page_content.html

@@ -54,17 +54,18 @@
   <div id="page-editor-navbar-bottom-container" class="d-none d-edit-block"></div>
 </div>
 
-<div class="d-none d-lg-block d-editor-none grw-side-contents-container ml-4">
+{% if revision %}
+<div class="d-none d-lg-block d-edit-none grw-side-contents-container">
   <div class="grw-side-contents-sticky-container">
     <div id="page-accessories" class="page-accessories"></div>
-    <div id="revision-toc" class="revision-toc sps sps--abv" data-sps-offset="123">
-      <div id="revision-toc-content" class="revision-toc-content"></div>
-    </div>
+    <div id="revision-toc" class="revision-toc sps sps--abv" data-sps-offset="123"></div>
     {% if pageUser %}
       <div id="grw-user-contents-links"></div>
     {% endif %}
   </div>
 </div>
+{% endif %}
+
 
 <div id="grw-page-status-alert-container"></div>
 

+ 2 - 2
src/test/middlewares/login-required.test.js

@@ -228,7 +228,7 @@ describe('loginRequired', () => {
       expect(res.redirect).not.toHaveBeenCalled();
       expect(res.sendStatus).not.toHaveBeenCalled();
       expect(fallbackMock).toHaveBeenCalledTimes(1);
-      expect(fallbackMock).toHaveBeenCalledWith(req, res);
+      expect(fallbackMock).toHaveBeenCalledWith(req, res, next);
       expect(result).toBe('fallback');
     });
 
@@ -242,7 +242,7 @@ describe('loginRequired', () => {
       expect(res.sendStatus).not.toHaveBeenCalled();
       expect(res.redirect).not.toHaveBeenCalled();
       expect(fallbackMock).toHaveBeenCalledTimes(1);
-      expect(fallbackMock).toHaveBeenCalledWith(req, res);
+      expect(fallbackMock).toHaveBeenCalledWith(req, res, next);
       expect(result).toBe('fallback');
     });