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

Merge branch 'master' into imprv/gw4454-show-subnav-title

# Conflicts:
#	src/client/js/components/Navbar/GrowiSubNavigation.jsx
kaori 5 лет назад
Родитель
Сommit
0e5b3b1011
60 измененных файлов с 704 добавлено и 489 удалено
  1. 7 12
      .devcontainer/docker-compose.yml
  2. 6 5
      resource/locales/en_US/admin/admin.json
  3. 2 1
      resource/locales/ja_JP/admin/admin.json
  4. 6 5
      resource/locales/zh_CN/admin/admin.json
  5. 5 3
      src/client/js/app.jsx
  6. 1 1
      src/client/js/components/Admin/Customize/ThemeColorBox.jsx
  7. 18 10
      src/client/js/components/Admin/ManageExternalAccount.jsx
  8. 1 1
      src/client/js/components/Admin/Security/ShareLinkSetting.jsx
  9. 11 7
      src/client/js/components/Admin/UserGroup/UserGroupPage.jsx
  10. 10 8
      src/client/js/components/Admin/UserGroupDetail/UserGroupPageList.jsx
  11. 1 1
      src/client/js/components/Admin/UserManagement.jsx
  12. 3 3
      src/client/js/components/Admin/Users/UserInviteModal.jsx
  13. 1 1
      src/client/js/components/Admin/Users/UserMenu.jsx
  14. 54 0
      src/client/js/components/ForbiddenPage.jsx
  15. 0 1
      src/client/js/components/MyDraftList/MyDraftList.jsx
  16. 1 1
      src/client/js/components/Navbar/DrawerToggler.jsx
  17. 11 1
      src/client/js/components/Navbar/GrowiNavbar.jsx
  18. 27 40
      src/client/js/components/Navbar/GrowiSubNavigation.jsx
  19. 4 4
      src/client/js/components/Page/NotFoundAlert.jsx
  20. 23 26
      src/client/js/components/Page/RenderTagLabels.jsx
  21. 0 5
      src/client/js/components/Page/RevisionPathControls.jsx
  22. 3 0
      src/client/js/components/Page/TagLabels.jsx
  23. 4 8
      src/client/js/components/PageAccessories.jsx
  24. 14 10
      src/client/js/components/PageAccessoriesModal.jsx
  25. 57 52
      src/client/js/components/PageAccessoriesModalControl.jsx
  26. 1 1
      src/client/js/components/PageAttachment.jsx
  27. 22 20
      src/client/js/components/PageComment/CommentEditor.jsx
  28. 5 5
      src/client/js/components/PageEditor/EditorNavbarBottom.jsx
  29. 3 3
      src/client/js/components/PageEditor/OptionsSelector.jsx
  30. 1 1
      src/client/js/components/PageList.jsx
  31. 55 76
      src/client/js/components/PaginationWrapper.jsx
  32. 1 1
      src/client/js/components/SavePageControls.jsx
  33. 3 2
      src/client/js/components/User/SeenUserInfo.jsx
  34. 8 2
      src/client/js/legacy/crowi.js
  35. 6 3
      src/client/js/services/AppContainer.js
  36. 102 35
      src/client/js/services/PageContainer.js
  37. 1 1
      src/client/js/services/PageHistoryContainer.js
  38. 1 1
      src/client/styles/scss/_admin.scss
  39. 1 1
      src/client/styles/scss/_mixins.scss
  40. 29 18
      src/client/styles/scss/_on-edit.scss
  41. 28 0
      src/client/styles/scss/_override-bootstrap-variables.scss
  42. 4 0
      src/client/styles/scss/_subnav.scss
  43. 2 2
      src/client/styles/scss/_variables.scss
  44. 8 6
      src/client/styles/scss/_wiki.scss
  45. 6 2
      src/client/styles/scss/theme/_apply-colors.scss
  46. 2 1
      src/client/styles/scss/theme/antarctic.scss
  47. 1 0
      src/client/styles/scss/theme/christmas.scss
  48. 1 1
      src/server/models/page.js
  49. 48 0
      src/server/models/serializers/page-serializer.js
  50. 27 0
      src/server/models/serializers/user-serializer.js
  51. 3 7
      src/server/models/user.js
  52. 30 0
      src/server/models/vo/s2c-message.js
  53. 1 1
      src/server/routes/apiv3/attachment.js
  54. 1 1
      src/server/routes/index.js
  55. 8 7
      src/server/routes/page.js
  56. 0 20
      src/server/service/page.js
  57. 17 16
      src/server/service/system-events/sync-page-status.js
  58. 8 2
      src/server/views/layout-growi/forbidden.html
  59. 0 46
      src/server/views/widget/forbidden_content.html
  60. 0 1
      src/server/views/widget/page_content.html

+ 7 - 12
.devcontainer/docker-compose.yml

@@ -22,19 +22,12 @@ services:
       - 3001:3001 # for browser-sync
 
     volumes:
-      - ..:/workspace/growi:cached
-      - /workspace/growi/node_modules
-      - ../../growi-docker-compose:/workspace/growi-docker-compose:cached
-      - ../../node_modules:/workspace/node_modules:cached
+      - ..:/workspace/growi:delegated
+      - node_modules:/workspace/growi/node_modules
+      - ../../growi-docker-compose:/workspace/growi-docker-compose:delegated
+      - ../../node_modules:/workspace/node_modules:delegated
 
-
-    # Overrides default command so things don't shut down after the process ends.
-    command: sleep infinity
-
-    links:
-      - mongo
-      - elasticsearch
-      - hackmd
+    tty: true
 
   mongo:
     image: mongo:4.4
@@ -86,3 +79,5 @@ services:
       - 3010:3000
     volumes:
       - /files/sqlite
+volumes:
+  node_modules:

+ 6 - 5
resource/locales/en_US/admin/admin.json

@@ -116,19 +116,19 @@
       "tab_switch_desc1": "Save edit tab and history tab switching in the browser and make it object for forward/back command of the browser.",
       "tab_switch_desc2": "By invalidating, you can make page transition as the only object for forward/back command of the browser.",
       "attach_title_header": "Add h1 section when create new page automatically",
-      "attach_title_header_desc": "Add page path to the first line as h1 section when create new page",
+      "attach_title_header_desc": "Add page path to the first line as h1 section when create new page.",
 
       "list_num_s": "Number of list displayed on modals",
-      "list_num_desc_s": "Set number of list per page such as 'Pagelist', 'Timeline', 'Page History' and 'Share Link' pages",
+      "list_num_desc_s": "Set number of list per page such as 'Page List', 'Timeline', 'Page History' and 'Attachment' pages.",
 
       "list_num_m": "Number of list displayed on article pages included other contents",
-      "list_num_desc_m": "Set number of list per page such as 'Bookmarks' and 'Recently created' pages",
+      "list_num_desc_m": "Set number of list per page such as 'Bookmarks' and 'Recently created' pages.",
 
       "list_num_l": "Number of list displayed on 'Search' pages",
-      "list_num_desc_l": "Set number of list per page such as 'Search' pages",
+      "list_num_desc_l": "Set number of list per page such as 'Search' pages.",
 
       "list_num_xl": "Number of list displayed on article pages",
-      "list_num_desc_xl": "Set number of list per page such as 'Not found' and 'Trash' pages",
+      "list_num_desc_xl": "Set number of list per page such as 'Not found' and 'Trash' pages.",
 
 
 
@@ -275,6 +275,7 @@
     "external_accounts":"External accounts",
     "create_external_account":"Create external account",
     "external_account_list": "External Account List",
+    "external_account_none":"No External Account",
     "invite": "Invite",
     "invited": "User was invited",
     "back_to_user_management": "Back to User Management",

+ 2 - 1
resource/locales/ja_JP/admin/admin.json

@@ -119,7 +119,7 @@
       "attach_title_header_desc": "新規作成したページの1行目に、ページのパスを h1 セクションとして挿入します。",
 
       "list_num_s": "モーダルに表示されるリスト数",
-      "list_num_desc_s": "モーダルにおける <Pagelist> <Timeline> <Page History> <Share Link>での、1ページあたりの表示数を設定します。",
+      "list_num_desc_s": "モーダルにおける <ページリスト> <タイムライン> <更新履歴> <添付ファイル>での、1ページあたりの表示数を設定します。",
 
       "list_num_m": "ユーザーページに表示されるリスト数",
       "list_num_desc_m": "ユーザーページにおける <Bookmarks> <Recently Created>での、1ページあたりの表示数を設定します。",
@@ -273,6 +273,7 @@
     "external_accounts": "外部アカウント",
     "create_external_account":"外部アカウントの作成",
     "external_account_list": "外部アカウント一覧",
+    "external_account_none":"外部アカウントはありません",
     "invite": "招待する",
     "invited": "ユーザーを招待しました",
     "back_to_user_management": "ユーザー管理に戻る",

+ 6 - 5
resource/locales/zh_CN/admin/admin.json

@@ -129,16 +129,16 @@
       "attach_title_header_desc": "创建新页面时,将页面路径作为h1节添加到第一行",
 
       "list_num_s": "Number of list displayed on modals",
-      "list_num_desc_s": "Set number of list per page such as 'Pagelist', 'Timeline', 'Page History' and 'Share Link' pages",
+      "list_num_desc_s": "Set number of list per page such as 'Page List', 'Timeline', 'Page History' and 'Attachment' pages.",
 
       "list_num_m": "Number of list displayed on article pages included other contents",
-      "list_num_desc_m": "Set number of list per page such as 'Bookmarks' and 'Recently created' pages",
+      "list_num_desc_m": "Set number of list per page such as 'Bookmarks' and 'Recently created' pages.",
 
       "list_num_l": "Number of list displayed on 'Search' pages",
-      "list_num_desc_l": "Set number of list per page such as 'Search' pages",
+      "list_num_desc_l": "Set number of list per page such as 'Search' pages.",
 
       "list_num_xl": "Number of list displayed on article pages",
-      "list_num_desc_xl": "Set number of list per page such as 'Not found' and 'Trash' pages",
+      "list_num_desc_xl": "Set number of list per page such as 'Not found' and 'Trash' pages.",
 
 			"stale_notification": "在过期页上显示通知",
 			"stale_notification_desc": "显示自上次更新以来超过1年的页面通知。",
@@ -282,7 +282,8 @@
 		"external_account": "外部账户管理",
 		"external_accounts": "外部账户",
 		"create_external_account": "创建外部账户",
-		"external_account_list": "外部账户列表",
+    "external_account_list": "外部账户列表",
+    "external_account_none":"No External Account",
 		"invite": "邀请",
 		"invited": "已邀请用户",
 		"back_to_user_management": "返回用户管理",

+ 5 - 3
src/client/js/app.jsx

@@ -21,6 +21,7 @@ import TrashPageList from './components/TrashPageList';
 import TrashPageAlert from './components/Page/TrashPageAlert';
 import NotFoundPage from './components/NotFoundPage';
 import NotFoundAlert from './components/Page/NotFoundAlert';
+import ForbiddenPage from './components/ForbiddenPage';
 import PageStatusAlert from './components/PageStatusAlert';
 import RecentCreated from './components/RecentCreated/RecentCreated';
 import RecentlyCreatedIcon from './components/Icons/RecentlyCreatedIcon';
@@ -86,13 +87,14 @@ Object.assign(componentMappings, {
   'trash-page-list': <TrashPageList />,
 
   'not-found-page': <NotFoundPage />,
-
   'not-found-alert': <NotFoundAlert
     onPageCreateClicked={navigationContainer.setEditorMode}
-    isGuestUserMode={appContainer.currentUser == null}
-    isHidden={pageContainer.state.isForbidden || pageContainer.state.isNotCreatable || pageContainer.state.isTrashPage}
+    isGuestUserMode={appContainer.isGuestUser}
+    isHidden={pageContainer.state.isNotCreatable || pageContainer.state.isTrashPage}
   />,
 
+  'forbidden-page': <ForbiddenPage />,
+
   'page-timeline': <PageTimeline />,
 
   'personal-setting': <PersonalSettings crowi={personalContainer} />,

+ 1 - 1
src/client/js/components/Admin/Customize/ThemeColorBox.jsx

@@ -15,7 +15,7 @@ class ThemeColorBox extends React.PureComponent {
         className={`theme-option-container d-flex flex-column align-items-center ${isSelected && 'active'}`}
         onClick={onSelected}
       >
-        <a id={name} type="button" className={`m-0 ${name} theme-button`}>
+        <a id={name} role="button" className={`m-0 ${name} theme-button`}>
           <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
             <g>
               <path d="M -1 -1 L65 -1 L65 65 L-1 65 L-1 -1 Z" fill={bg}></path>

+ 18 - 10
src/client/js/components/Admin/ManageExternalAccount.jsx

@@ -34,18 +34,18 @@ class ManageExternalAccount extends React.Component {
 
   render() {
     const { t, adminExternalAccountsContainer } = this.props;
+    const { activePage, totalAccounts, pagingLimit } = adminExternalAccountsContainer.state;
 
-    const pager = (
 
+    const pager = (
       <PaginationWrapper
-        activePage={adminExternalAccountsContainer.state.activePage}
+        activePage={activePage}
         changePage={this.handleExternalAccountPage}
-        totalItemsCount={adminExternalAccountsContainer.state.totalAccounts}
-        pagingLimit={adminExternalAccountsContainer.state.pagingLimit}
-        align="right"
+        totalItemsCount={totalAccounts}
+        pagingLimit={pagingLimit}
+        align="center"
         size="sm"
       />
-
     );
     return (
       <Fragment>
@@ -57,10 +57,18 @@ class ManageExternalAccount extends React.Component {
         </p>
 
         <h2>{t('admin:user_management.external_account_list')}</h2>
-
-        {pager}
-        <ExternalAccountTable />
-        {pager}
+        {(totalAccounts !== 0) ? (
+          <>
+            {pager}
+            <ExternalAccountTable />
+            {pager}
+          </>
+         )
+         : (
+           <>
+             {t('admin:user_management.external_account_none')}
+           </>
+)}
 
       </Fragment>
     );

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

@@ -24,7 +24,7 @@ const Pager = (props) => {
       changePage={props.handlePage}
       totalItemsCount={props.totalLinks}
       pagingLimit={props.limit}
-      align="right"
+      align="center"
       size="sm"
     />
   );

+ 11 - 7
src/client/js/components/Admin/UserGroup/UserGroupPage.jsx

@@ -152,13 +152,17 @@ class UserGroupPage extends React.Component {
           onDelete={this.showDeleteModal}
           userGroupRelations={this.state.userGroupRelations}
         />
-        <PaginationWrapper
-          activePage={this.state.activePage}
-          changePage={this.handlePage}
-          totalItemsCount={this.state.totalUserGroups}
-          pagingLimit={this.state.pagingLimit}
-          size="sm"
-        />
+        {this.state.userGroups.length === 0
+        ? <p>No groups yet</p> : (
+          <PaginationWrapper
+            activePage={this.state.activePage}
+            changePage={this.handlePage}
+            totalItemsCount={this.state.totalUserGroups}
+            pagingLimit={this.state.pagingLimit}
+            align="center"
+            size="sm"
+          />
+        )}
         <UserGroupDeleteModal
           userGroups={this.state.userGroups}
           deleteUserGroup={this.state.selectedUserGroup}

+ 10 - 8
src/client/js/components/Admin/UserGroupDetail/UserGroupPageList.jsx

@@ -58,14 +58,16 @@ class UserGroupPageList extends React.Component {
         <ul className="page-list-ul page-list-ul-flat mb-3">
           {this.state.currentPages.map(page => <li key={page._id}><Page page={page} /></li>)}
         </ul>
-        {adminUserGroupDetailContainer.state.relatedPages.length === 0 ? <p>{t('admin:user_group_management.no_pages')}</p> : null}
-        <PaginationWrapper
-          activePage={this.state.activePage}
-          changePage={this.handlePageChange}
-          totalItemsCount={this.state.total}
-          pagingLimit={this.state.pagingLimit}
-          size="sm"
-        />
+        {adminUserGroupDetailContainer.state.relatedPages.length === 0 ? <p>{t('admin:user_group_management.no_pages')}</p> : (
+          <PaginationWrapper
+            activePage={this.state.activePage}
+            changePage={this.handlePageChange}
+            totalItemsCount={this.state.total}
+            pagingLimit={this.state.pagingLimit}
+            align="center"
+            size="sm"
+          />
+        )}
       </Fragment>
     );
   }

+ 1 - 1
src/client/js/components/Admin/UserManagement.jsx

@@ -120,7 +120,7 @@ class UserManagement extends React.Component {
           changePage={this.handlePage}
           totalItemsCount={adminUsersContainer.state.totalUsers}
           pagingLimit={adminUsersContainer.state.pagingLimit}
-          align="right"
+          align="center"
           size="sm"
         />
       </div>

+ 3 - 3
src/client/js/components/Admin/Users/UserInviteModal.jsx

@@ -131,9 +131,9 @@ class UserInviteModal extends React.Component {
         {userList.map((user) => {
           const copyText = `Email:${user.email} Password:${user.password} `;
           return (
-            <div className="my-1">
-              <CopyToClipboard key={user.email} text={copyText} onCopy={this.showToaster}>
-                <li key={user.email} className="btn btn-outline-secondary">
+            <div className="my-1" key={user.email}>
+              <CopyToClipboard text={copyText} onCopy={this.showToaster}>
+                <li className="btn btn-outline-secondary">
                 Email: <strong className="mr-3">{user.email}</strong> Password: <strong>{user.password}</strong>
                 </li>
               </CopyToClipboard>

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

@@ -80,7 +80,7 @@ class UserMenu extends React.Component {
 
     return (
       <Fragment>
-        <div className="btn-group admin-user-menu" role="group">
+        <div className="btn-group admin-user-menu position-absolute" role="group">
           <button id="userMenu" type="button" className="btn btn-outline-secondary btn-sm dropdown-toggle" data-toggle="dropdown">
             <i className="icon-settings"></i>
           </button>

+ 54 - 0
src/client/js/components/ForbiddenPage.jsx

@@ -0,0 +1,54 @@
+import React, { useMemo } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import PageListIcon from './Icons/PageListIcon';
+import CustomNavigation from './CustomNavigation';
+import PageList from './PageList';
+
+
+const ForbiddenPage = (props) => {
+  const { t } = props;
+
+  const navTabMapping = useMemo(() => {
+    return {
+      pagelist: {
+        Icon: PageListIcon,
+        Content: PageList,
+        i18n: t('page_list'),
+        index: 0,
+      },
+    };
+  }, [t]);
+
+  return (
+    <>
+      <div className="row not-found-message-row mb-4">
+        <div className="col-lg-12">
+          <h2 className="text-muted">
+            <i className="icon-ban mr-2" aria-hidden="true" />
+            Forbidden
+          </h2>
+        </div>
+      </div>
+
+
+      <div className="row row-alerts d-edit-none">
+        <div className="col-sm-12">
+          <p className="alert alert-primary py-3 px-4">
+            <i className="icon-fw icon-lock" aria-hidden="true" />
+            {t('Browsing of this page is restricted')}
+          </p>
+        </div>
+      </div>
+      <div className="mt-5">
+        <CustomNavigation navTabMapping={navTabMapping} />
+      </div>
+    </>
+  );
+};
+
+ForbiddenPage.propTypes = {
+  t: PropTypes.func.isRequired,
+};
+
+export default withTranslation()(ForbiddenPage);

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

@@ -22,7 +22,6 @@ class MyDraftList extends React.Component {
       currentDrafts: [],
       activePage: 1,
       totalDrafts: 0,
-      // [TODO: rename pageLimitationM to pageLimitationL]
       pagingLimit: Infinity,
     };
 

+ 1 - 1
src/client/js/components/Navbar/DrawerToggler.jsx

@@ -18,7 +18,7 @@ const DrawerToggler = (props) => {
 
   return (
     <button
-      className="grw-drawer-toggler btn btn-secondary btn-xl"
+      className="grw-drawer-toggler btn btn-secondary"
       type="button"
       aria-expanded="false"
       aria-label="Toggle navigation"

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

@@ -3,10 +3,12 @@ import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
 
+import { UncontrolledTooltip } from 'reactstrap';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import NavigationContainer from '../../services/NavigationContainer';
 import AppContainer from '../../services/AppContainer';
 
+
 import GrowiLogo from '../Icons/GrowiLogo';
 
 import PersonalDropdown from './PersonalDropdown';
@@ -45,10 +47,18 @@ class GrowiNavbar extends React.Component {
 
     return (
       <li className="nav-item confidential text-light">
-        <i className="icon-info d-md-none" data-toggle="tooltip" title={crowi.confidential} />
+        <i id="confidentialTooltip" className="icon-info d-md-none" />
         <span className="d-none d-md-inline">
           {crowi.confidential}
         </span>
+        <UncontrolledTooltip
+          placement="bottom"
+          trigger="click"
+          target="confidentialTooltip"
+          className="d-md-none"
+        >
+          {crowi.confidential}
+        </UncontrolledTooltip>
       </li>
     );
   }

+ 27 - 40
src/client/js/components/Navbar/GrowiSubNavigation.jsx

@@ -1,10 +1,8 @@
-import React, { useMemo } from 'react';
+import React from 'react';
 import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
 
-import { isTrashPage } from '@commons/util/path-utils';
-
 import DevidedPagePath from '@commons/models/devided-page-path';
 import LinkedPagePath from '@commons/models/linked-page-path';
 import PagePathHierarchicalLink from '@commons/components/PagePathHierarchicalLink';
@@ -26,20 +24,22 @@ import DrawerToggler from './DrawerToggler';
 import PageManagement from '../Page/PageManagement';
 
 
-// eslint-disable-next-line react/prop-types
-const PagePathNav = ({ pageId, pagePath, isPageForbidden }) => {
+const PagePathNav = ({
+  // eslint-disable-next-line react/prop-types
+  pageId, pagePath, isEditorMode,
+}) => {
 
   const dPagePath = new DevidedPagePath(pagePath, false, true);
 
   let formerLink;
   let latterLink;
 
-  // when the path is root or first level
-  if (dPagePath.isRoot || dPagePath.isFormerRoot) {
+  // one line
+  if (dPagePath.isRoot || dPagePath.isFormerRoot || isEditorMode) {
     const linkedPagePath = new LinkedPagePath(pagePath);
     latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePath} />;
   }
-  // when the path is second level or deeper
+  // two line
   else {
     const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
     const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
@@ -56,7 +56,6 @@ const PagePathNav = ({ pageId, pagePath, isPageForbidden }) => {
           <RevisionPathControls
             pageId={pageId}
             pagePath={pagePath}
-            isPageForbidden={isPageForbidden}
           />
         </div>
       </span>
@@ -70,20 +69,12 @@ const PagePathNav = ({ pageId, pagePath, isPageForbidden }) => {
 /* eslint-disable react/prop-types */
 const PageReactionButtons = ({ appContainer, pageContainer }) => {
 
-  const {
-    pageUser, shareLinkId,
-  } = pageContainer.state;
-
-  const isSharedPage = useMemo(() => {
-    return shareLinkId != null;
-  }, [shareLinkId]);
-
   return (
     <>
-      {pageUser == null && !isSharedPage && (
-      <span className="mr-2">
-        <LikeButton />
-      </span>
+      {pageContainer.isAbleToShowLikeButton && (
+        <span className="mr-2">
+          <LikeButton />
+        </span>
       )}
       <span>
         <BookmarkButton crowi={appContainer} />
@@ -99,17 +90,13 @@ const GrowiSubNavigation = (props) => {
   } = props;
   const { isDrawerMode, editorMode } = navigationContainer.state;
   const {
-    pageId, path, createdAt, creator, updatedAt, revisionAuthor,
-    isForbidden: isPageForbidden, pageUser, isNotCreatable, shareLinkId,
+    pageId, path, createdAt, creator, updatedAt, revisionAuthor, isPageExist,
   } = pageContainer.state;
 
-  const { currentUser } = appContainer;
-  const isPageNotFound = pageId == null;
+  const { isGuestUser } = appContainer;
+  const isEditorMode = editorMode !== 'view';
   // Tags cannot be edited while the new page and editorMode is view
-  const isTagLabelHidden = (editorMode !== 'edit' && isPageNotFound);
-  const isUserPage = pageUser != null;
-  const isPageInTrash = isTrashPage(path);
-  const isSharedPage = shareLinkId != null;
+  const isTagLabelHidden = (editorMode !== 'edit' && !isPageExist);
 
   function onThreeStrandedButtonClicked(viewType) {
     navigationContainer.setEditorMode(viewType);
@@ -121,34 +108,34 @@ const GrowiSubNavigation = (props) => {
       {/* Left side */}
       <div className="d-flex grw-subnav-left-side">
         { isDrawerMode && (
-          <div className="d-none d-md-flex align-items-center border-right mr-md-3 pr-md-3">
+          <div className={`d-none d-md-flex align-items-center ${isEditorMode ? 'mr-2 pr-2' : 'border-right mr-4 pr-4'}`}>
             <DrawerToggler />
           </div>
         ) }
 
         <div className="grw-path-nav-container">
-          { !isCompactMode && !isTagLabelHidden && !isPageForbidden && !isUserPage && !isSharedPage && (
-            <div className="mb-2">
+          { pageContainer.isAbleToShowTagLabel && !isCompactMode && !isTagLabelHidden && (
+            <div className="grw-taglabels-container">
               <TagLabels editorMode={editorMode} />
             </div>
           ) }
-          <PagePathNav pageId={pageId} pagePath={path} isPageForbidden={isPageForbidden} />
+          <PagePathNav pageId={pageId} pagePath={path} isEditorMode={isEditorMode} />
         </div>
       </div>
 
       {/* Right side */}
       <div className="d-flex">
 
-        <div className="d-flex flex-column align-items-end">
+        <div className={`d-flex ${isEditorMode ? 'align-items-center' : 'flex-column align-items-end'}`}>
           <div className="d-flex">
-            { !isPageInTrash && !isPageNotFound && !isPageForbidden && <PageReactionButtons appContainer={appContainer} pageContainer={pageContainer} /> }
-            { !isPageNotFound && !isPageForbidden && <PageManagement isCompactMode={isCompactMode} /> }
+            { pageContainer.isAbleToShowPageReactionButtons && <PageReactionButtons appContainer={appContainer} pageContainer={pageContainer} /> }
+            { pageContainer.isAbleToShowPageManagement && <PageManagement isCompactMode={isCompactMode} /> }
           </div>
-          <div className="mt-2">
-            {!isNotCreatable && !isPageInTrash && !isPageForbidden && (
+          <div className={`${isEditorMode ? 'ml-2' : 'mt-2'}`}>
+            {pageContainer.isAbleToShowThreeStrandedButton && (
               <ThreeStrandedButton
                 onThreeStrandedButtonClicked={onThreeStrandedButtonClicked}
-                isBtnDisabled={currentUser == null}
+                isBtnDisabled={isGuestUser}
                 editorMode={editorMode}
               />
             )}
@@ -156,7 +143,7 @@ const GrowiSubNavigation = (props) => {
         </div>
 
         {/* Page Authors */}
-        { (!isCompactMode && !isUserPage && !isPageNotFound && !isPageForbidden) && (
+        { (pageContainer.isAbleToShowPageAuthors && !isCompactMode) && (
           <ul className="authors text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3">
             <li className="pb-1">
               <AuthorInfo user={creator} date={createdAt} locate="subnav" />

+ 4 - 4
src/client/js/components/Page/NotFoundAlert.jsx

@@ -41,10 +41,10 @@ const NotFoundAlert = (props) => {
 
 
         {isGuestUserMode && (
-        <UncontrolledTooltip placement="bottom" target="create-page-btn-wrapper-for-tooltip" fade={false}>
-          {t('Not available for guest')}
-        </UncontrolledTooltip>
-      )}
+          <UncontrolledTooltip placement="bottom" target="create-page-btn-wrapper-for-tooltip" fade={false}>
+            {t('Not available for guest')}
+          </UncontrolledTooltip>
+        )}
       </div>
     </div>
   );

+ 23 - 26
src/client/js/components/Page/RenderTagLabels.jsx

@@ -2,12 +2,12 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
-import { withUnstatedContainers } from '../UnstatedUtils';
-import PageContainer from '../../services/PageContainer';
+import { UncontrolledTooltip } from 'reactstrap';
 
-function RenderTagLabels(props) {
-  const { t, tags, pageContainer } = props;
-  const { pageId } = pageContainer;
+const RenderTagLabels = React.memo((props) => {
+  const {
+    t, tags, pageId, isGuestUser,
+  } = props;
 
   function openEditorHandler() {
     if (props.openEditorModal == null) {
@@ -35,35 +35,32 @@ function RenderTagLabels(props) {
     <>
       {tagElements}
 
-      <a className={`btn btn-link btn-edit-tags p-0 text-muted ${isTagsEmpty ? 'no-tags' : ''}`} onClick={openEditorHandler}>
-        { isTagsEmpty
-          ? (
-            <>{ t('Add tags for this page') }<i className="ml-1 icon-plus"></i></>
-          )
-          : (
-            <i className="icon-plus"></i>
-          )
-        }
-      </a>
+      <div id="edit-tags-btn-wrapper-for-tooltip">
+        <a
+          className={`btn btn-link btn-edit-tags p-0 text-muted ${isTagsEmpty && 'no-tags'} ${isGuestUser && 'disabled'}`}
+          onClick={openEditorHandler}
+        >
+          { isTagsEmpty && <>{ t('Add tags for this page') }</>}
+          <i className="ml-1 icon-plus"></i>
+        </a>
+      </div>
+      {isGuestUser && (
+        <UncontrolledTooltip placement="top" target="edit-tags-btn-wrapper-for-tooltip" fade={false}>
+          {t('Not available for guest')}
+        </UncontrolledTooltip>
+      )}
     </>
   );
 
-}
-
-/**
- * Wrapper component for using unstated
- */
-const RenderTagLabelsWrapper = withUnstatedContainers(RenderTagLabels, [PageContainer]);
-
+});
 
 RenderTagLabels.propTypes = {
   t: PropTypes.func.isRequired, // i18next
 
   tags: PropTypes.array,
   openEditorModal: PropTypes.func,
-
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
+  isGuestUser: PropTypes.bool.isRequired,
+  pageId: PropTypes.string.isRequired,
 };
 
-export default withTranslation()(RenderTagLabelsWrapper);
+export default withTranslation()(RenderTagLabels);

+ 0 - 5
src/client/js/components/Page/RevisionPathControls.jsx

@@ -29,11 +29,6 @@ RevisionPathControls.propTypes = {
 
   pagePath: PropTypes.string.isRequired,
   pageId: PropTypes.string,
-  isPageForbidden: PropTypes.bool,
-};
-
-RevisionPathControls.defaultProps = {
-  isPageForbidden: false,
 };
 
 export default withTranslation()(RevisionPathControls);

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

@@ -74,6 +74,7 @@ class TagLabels extends React.Component {
 
   render() {
     const tags = this.getTagData();
+    const { appContainer, pageContainer } = this.props;
 
     return (
       <>
@@ -84,6 +85,8 @@ class TagLabels extends React.Component {
             <RenderTagLabels
               tags={tags}
               openEditorModal={this.openEditorModal}
+              pageId={pageContainer.state.pageId}
+              isGuestUser={appContainer.isGuestUser}
             />
           </Suspense>
         </form>

+ 4 - 8
src/client/js/components/PageAccessories.jsx

@@ -10,18 +10,14 @@ import PageAccessoriesContainer from '../services/PageAccessoriesContainer';
 
 const PageAccessories = (props) => {
   const { appContainer, pageAccessoriesContainer } = props;
-  const isGuestUserMode = appContainer.currentUser == null;
-
-  // not render only when this page is shared and user is not login.
-  if (appContainer.isSharedUser && isGuestUserMode) {
-    return null;
-  }
+  const { isGuestUser, isSharedUser } = appContainer;
 
   return (
     <>
-      <PageAccessoriesModalControl isGuestUserMode={isGuestUserMode} />
+      <PageAccessoriesModalControl isGuestUser={isGuestUser} isSharedUser={isSharedUser} />
       <PageAccessoriesModal
-        isGuestUserMode={isGuestUserMode}
+        isGuestUser={isGuestUser}
+        isSharedUser={isSharedUser}
         isOpen={pageAccessoriesContainer.state.isPageAccessoriesModalShown}
         onClose={pageAccessoriesContainer.closePageAccessoriesModal}
       />

+ 14 - 10
src/client/js/components/PageAccessoriesModal.jsx

@@ -24,7 +24,7 @@ import ExpandOrContractButton from './ExpandOrContractButton';
 
 const PageAccessoriesModal = (props) => {
   const {
-    t, pageAccessoriesContainer, onClose, isGuestUserMode,
+    t, pageAccessoriesContainer, onClose, isGuestUser, isSharedUser,
   } = props;
   const { switchActiveTab } = pageAccessoriesContainer;
   const { activeTab, activeComponents } = pageAccessoriesContainer.state;
@@ -36,16 +36,19 @@ const PageAccessoriesModal = (props) => {
         Icon: PageListIcon,
         i18n: t('page_list'),
         index: 0,
+        isLinkEnabled: v => !isSharedUser,
       },
-      timeline:  {
+      timeline: {
         Icon: TimeLineIcon,
         i18n: t('Timeline View'),
         index: 1,
+        isLinkEnabled: v => !isSharedUser,
       },
       pageHistory: {
         Icon: HistoryIcon,
         i18n: t('History'),
         index: 2,
+        isLinkEnabled: v => !isGuestUser && !isSharedUser,
       },
       attachment: {
         Icon: AttachmentIcon,
@@ -56,10 +59,10 @@ const PageAccessoriesModal = (props) => {
         Icon: ShareLinkIcon,
         i18n: t('share_links.share_link_management'),
         index: 4,
-        isLinkEnabled: v => !isGuestUserMode,
+        isLinkEnabled: v => !isGuestUser && !isSharedUser,
       },
     };
-  }, [t, isGuestUserMode]);
+  }, [t, isGuestUser, isSharedUser]);
 
   const closeModalHandler = useCallback(() => {
     if (onClose == null) {
@@ -106,15 +109,15 @@ const PageAccessoriesModal = (props) => {
             <TabPane tabId="timeline">
               {activeComponents.has('timeline') && <PageTimeline /> }
             </TabPane>
-            <TabPane tabId="pageHistory">
-              <div className="overflow-auto">
+            {!isGuestUser && (
+              <TabPane tabId="pageHistory">
                 {activeComponents.has('pageHistory') && <PageHistory /> }
-              </div>
-            </TabPane>
+              </TabPane>
+            )}
             <TabPane tabId="attachment">
               {activeComponents.has('attachment') && <PageAttachment />}
             </TabPane>
-            {!isGuestUserMode && (
+            {!isGuestUser && (
               <TabPane tabId="shareLink">
                 {activeComponents.has('shareLink') && <ShareLink />}
               </TabPane>
@@ -134,7 +137,8 @@ const PageAccessoriesModalWrapper = withUnstatedContainers(PageAccessoriesModal,
 PageAccessoriesModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
-  isGuestUserMode: PropTypes.bool.isRequired,
+  isGuestUser: PropTypes.bool.isRequired,
+  isSharedUser: PropTypes.bool.isRequired,
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func,
 };

+ 57 - 52
src/client/js/components/PageAccessoriesModalControl.jsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { Fragment, useMemo } from 'react';
 import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
@@ -16,61 +16,65 @@ import SeenUserInfo from './User/SeenUserInfo';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 const PageAccessoriesModalControl = (props) => {
-  const { t, pageAccessoriesContainer, isGuestUserMode } = props;
+  const {
+    t, pageAccessoriesContainer, isGuestUser, isSharedUser,
+  } = props;
+
+  const accessoriesBtnList = useMemo(() => {
+    return [
+      {
+        name: 'pagelist',
+        Icon: <PageListIcon />,
+        disabled: isSharedUser,
+      },
+      {
+        name: 'timeline',
+        Icon: <TimeLineIcon />,
+        disabled: isSharedUser,
+      },
+      {
+        name: 'pageHistory',
+        Icon: <HistoryIcon />,
+        disabled: isGuestUser || isSharedUser,
+      },
+      {
+        name: 'attachment',
+        Icon: <AttachmentIcon />,
+        disabled: false,
+      },
+      {
+        name: 'shareLink',
+        Icon: <ShareLinkIcon />,
+        disabled: isGuestUser || isSharedUser,
+      },
+    ];
+  }, [isGuestUser, isSharedUser]);
 
   return (
     <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"
-        onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('pagelist')}
-      >
-        <PageListIcon />
-      </button>
-
-      <button
-        type="button"
-        className="btn btn-link grw-btn-page-accessories"
-        onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('timeline')}
-      >
-        <TimeLineIcon />
-      </button>
-
-      <button
-        type="button"
-        className="btn btn-link grw-btn-page-accessories"
-        onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('pageHistory')}
-      >
-        <HistoryIcon />
-      </button>
-
-      <button
-        type="button"
-        className="btn btn-link grw-btn-page-accessories"
-        onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('attachment')}
-      >
-        <AttachmentIcon />
-      </button>
-
-      <div id="shareLink-btn-wrapper-for-tooltip">
-        <button
-          type="button"
-          className={`btn btn-link grw-btn-page-accessories ${isGuestUserMode && 'disabled'}`}
-          onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('shareLink')}
-        >
-          <ShareLinkIcon />
-        </button>
-      </div>
-      {isGuestUserMode && (
-        <UncontrolledTooltip placement="top" target="shareLink-btn-wrapper-for-tooltip" fade={false}>
-          {t('Not available for guest')}
-        </UncontrolledTooltip>
-      )}
-
+      {accessoriesBtnList.map((accessory) => {
+        return (
+          <Fragment key={accessory.name}>
+            <div id={`shareLink-btn-wrapper-for-tooltip-for-${accessory.name}`}>
+              <button
+                type="button"
+                className={`btn btn-link grw-btn-page-accessories ${accessory.disabled && 'disabled'}`}
+                onClick={() => pageAccessoriesContainer.openPageAccessoriesModal(accessory.name)}
+              >
+                {accessory.Icon}
+              </button>
+            </div>
+            {accessory.disabled && (
+              <UncontrolledTooltip placement="top" target={`shareLink-btn-wrapper-for-tooltip-for-${accessory.name}`} fade={false}>
+                {t('Not available for guest')}
+              </UncontrolledTooltip>
+            )}
+          </Fragment>
+        );
+      })}
       <div className="d-flex align-items-center">
         <span className="border-left grw-border-vr">&nbsp;</span>
-        <SeenUserInfo />
+        <SeenUserInfo disabled={isSharedUser} />
       </div>
     </div>
   );
@@ -85,7 +89,8 @@ PageAccessoriesModalControl.propTypes = {
 
   pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
 
-  isGuestUserMode: PropTypes.bool.isRequired,
+  isGuestUser: PropTypes.bool.isRequired,
+  isSharedUser: PropTypes.bool.isRequired,
 };
 
 export default withTranslation()(PageAccessoriesModalControlWrapper);

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

@@ -18,7 +18,7 @@ class PageAttachment extends React.Component {
     this.state = {
       activePage: 1,
       totalAttachments: 0,
-      limit: null,
+      limit: Infinity,
       attachments: [],
       inUse: {},
       attachmentToDelete: null,

+ 22 - 20
src/client/js/components/PageComment/CommentEditor.jsx

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
 
 import {
   Button,
-  TabContent, TabPane, Nav, NavItem, NavLink,
+  TabContent, TabPane,
 } from 'reactstrap';
 
 import * as toastr from 'toastr';
@@ -21,6 +21,20 @@ import SlackNotification from '../SlackNotification';
 
 import CommentPreview from './CommentPreview';
 import NotAvailableForGuest from '../NotAvailableForGuest';
+import { CustomNav } from '../CustomNavigation';
+
+const navTabMapping = {
+  comment_editor: {
+    Icon: () => <i className="icon-settings" />,
+    i18n: 'Write',
+    index: 0,
+  },
+  comment_preview: {
+    Icon: () => <i className="icon-settings" />,
+    i18n: 'Preview',
+    index: 1,
+  },
+};
 
 /**
  *
@@ -43,7 +57,7 @@ class CommentEditor extends React.Component {
       comment: this.props.commentBody || '',
       isMarkdown: true,
       html: '',
-      activeTab: 1,
+      activeTab: 'comment_editor',
       isUploadable,
       isUploadableFile,
       errorMessage: undefined,
@@ -94,7 +108,7 @@ class CommentEditor extends React.Component {
       comment: '',
       isMarkdown: true,
       html: '',
-      activeTab: 1,
+      activeTab: 'comment_editor',
       errorMessage: undefined,
     });
     // reset value
@@ -280,25 +294,13 @@ class CommentEditor extends React.Component {
       </Button>
     );
 
+
     return (
       <>
         <div className="comment-write">
-          <Nav tabs>
-            <NavItem>
-              <NavLink type="button" className={activeTab === 1 ? 'active' : ''} onClick={() => this.handleSelect(1)}>
-                    Write
-              </NavLink>
-            </NavItem>
-            { this.state.isMarkdown && (
-            <NavItem>
-              <NavLink type="button" className={activeTab === 2 ? 'active' : ''} onClick={() => this.handleSelect(2)}>
-                      Preview
-              </NavLink>
-            </NavItem>
-                ) }
-          </Nav>
+          <CustomNav activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={this.handleSelect} hideBorderBottom />
           <TabContent activeTab={activeTab}>
-            <TabPane tabId={1}>
+            <TabPane tabId="comment_editor">
               <Editor
                 ref={(c) => { this.editor = c }}
                 value={this.state.comment}
@@ -313,7 +315,7 @@ class CommentEditor extends React.Component {
                 onCtrlEnter={this.ctrlEnterHandler}
               />
             </TabPane>
-            <TabPane tabId={2}>
+            <TabPane tabId="comment_preview">
               <div className="comment-form-preview">
                 {commentPreview}
               </div>
@@ -324,7 +326,7 @@ class CommentEditor extends React.Component {
         <div className="comment-submit">
           <div className="d-flex">
             <label className="mr-2">
-              {activeTab === 1 && (
+              {activeTab === 'comment_editor' && (
               <span className="custom-control custom-checkbox">
                 <input
                   type="checkbox"

+ 5 - 5
src/client/js/components/PageEditor/EditorNavbarBottom.jsx

@@ -24,7 +24,7 @@ const EditorNavbarBottom = (props) => {
   const {
     navigationContainer,
   } = props;
-  const { editorMode, isDrawerMode, isDeviceSmallerThanMd } = navigationContainer.state;
+  const { editorMode, isDeviceSmallerThanMd } = navigationContainer.state;
 
   const additionalClasses = ['grw-editor-navbar-bottom'];
 
@@ -76,12 +76,12 @@ const EditorNavbarBottom = (props) => {
         </Collapse>
         )
       }
-      <div className={`navbar navbar-expand border-top px-2 ${additionalClasses.join(' ')}`}>
+      <div className={`navbar navbar-expand border-top px-2 px-md-3 ${additionalClasses.join(' ')}`}>
         <form className="form-inline">
-          { isDrawerMode && renderDrawerButton() }
+          { isDeviceSmallerThanMd && renderDrawerButton() }
           { isOptionsSelectorEnabled && !isDeviceSmallerThanMd && <OptionsSelector /> }
         </form>
-        <form className="form-inline ml-auto">
+        <form className="form-inline flex-nowrap ml-auto">
           {/* Responsive Design for the SlackNotification */}
           {/* Button or the normal Slack banner */}
           {hasSlackConfig && (isDeviceSmallerThanMd ? (
@@ -115,7 +115,7 @@ const EditorNavbarBottom = (props) => {
         <Collapse isOpen={isExpanded}>
           <div className="px-2"> {/* set padding for border-top */}
             <div className={`navbar navbar-expand border-top px-0 ${additionalClasses.join(' ')}`}>
-              <form className="form-inline ml-md-auto">
+              <form className="form-inline ml-auto">
                 <OptionsSelector />
               </form>
             </div>

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

@@ -110,7 +110,7 @@ class OptionsSelector extends React.Component {
     });
 
     return (
-      <div className="input-group">
+      <div className="input-group flex-nowrap">
         <div className="input-group-prepend">
           <span className="input-group-text" id="igt-theme">Theme</span>
         </div>
@@ -146,7 +146,7 @@ class OptionsSelector extends React.Component {
     });
 
     return (
-      <div className="input-group">
+      <div className="input-group flex-nowrap">
         <div className="input-group-prepend">
           <span className="input-group-text" id="igt-keymap">Keymap</span>
         </div>
@@ -247,7 +247,7 @@ class OptionsSelector extends React.Component {
   render() {
     return (
       <div className="d-flex flex-row">
-        <span className="ml-sm-3">{this.renderThemeSelector()}</span>
+        <span>{this.renderThemeSelector()}</span>
         <span className="ml-2 ml-sm-4">{this.renderKeymapModeSelector()}</span>
         <span className="ml-2 ml-sm-4">{this.renderConfigurationDropdown()}</span>
       </div>

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

@@ -19,7 +19,7 @@ const PageList = (props) => {
 
   const [activePage, setActivePage] = useState(1);
   const [totalPages, setTotalPages] = useState(0);
-  const [limit, setLimit] = useState(null);
+  const [limit, setLimit] = useState(Infinity);
 
   function setPageNumber(selectedPageNumber) {
     setActivePage(selectedPageNumber);

+ 55 - 76
src/client/js/components/PaginationWrapper.jsx

@@ -1,42 +1,31 @@
-import React from 'react';
+import React, { useCallback, useMemo } from 'react';
 import PropTypes from 'prop-types';
 
-import { withTranslation } from 'react-i18next';
-
 import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
 
-class PaginationWrapper extends React.Component {
+/**
+ *
+ * @author Mikitaka Itizawa <itizawa@weseek.co.jp>
+ *
+ * @export
+ * @class PaginationWrapper
+ * @extends {React.Component}
+ */
 
-  constructor(props) {
-    super(props);
+const PaginationWrapper = React.memo((props) => {
+  const {
+    activePage, changePage, totalItemsCount, pagingLimit, align,
+  } = props;
 
-    this.state = {
-      activePage: 1,
-      totalItemsCount: 0,
-      paginationNumbers: {},
-      limit: this.props.pagingLimit || Infinity,
-    };
+  /**
+   * various numbers used to generate pagination dom
+   */
+  const paginationNumbers = useMemo(() => {
+    // avoid using null
+    const limit = pagingLimit || Infinity;
 
-    this.calculatePagination = this.calculatePagination.bind(this);
-  }
-
-  componentWillReceiveProps(nextProps) {
-    this.setState({
-      activePage: nextProps.activePage,
-      totalItemsCount: nextProps.totalItemsCount,
-      limit: nextProps.pagingLimit,
-    }, () => {
-      const activePage = this.state.activePage;
-      const totalCount = this.state.totalItemsCount;
-      const limit = this.state.limit;
-      const paginationNumbers = this.calculatePagination(limit, totalCount, activePage);
-      this.setState({ paginationNumbers });
-    });
-  }
-
-  calculatePagination(limit, totalCount, activePage) {
     // calc totalPageNumber
-    const totalPage = Math.floor(totalCount / limit) + (totalCount % limit === 0 ? 0 : 1);
+    const totalPage = Math.floor(totalItemsCount / limit) + (totalItemsCount % limit === 0 ? 0 : 1);
 
     let paginationStart = activePage - 2;
     let maxViewPageNum = activePage + 2;
@@ -58,22 +47,26 @@ class PaginationWrapper extends React.Component {
       paginationStart,
       maxViewPageNum,
     };
-  }
+  }, [activePage, totalItemsCount, pagingLimit]);
+
+  const { paginationStart } = paginationNumbers;
+  const { maxViewPageNum } = paginationNumbers;
+  const { totalPage } = paginationNumbers;
 
   /**
-    * generate Elements of Pagination First Prev
-    * ex.  <<   <   1  2  3  >  >>
-    * this function set << & <
-    */
-  generateFirstPrev(activePage) {
+   * generate Elements of Pagination First Prev
+   * ex.  <<   <   1  2  3  >  >>
+   * this function set << & <
+   */
+  const generateFirstPrev = useCallback(() => {
     const paginationItems = [];
     if (activePage !== 1) {
       paginationItems.push(
         <PaginationItem key="painationItemFirst">
-          <PaginationLink first onClick={() => { return this.props.changePage(1) }} />
+          <PaginationLink first onClick={() => { return changePage(1) }} />
         </PaginationItem>,
         <PaginationItem key="painationItemPrevious">
-          <PaginationLink previous onClick={() => { return this.props.changePage(activePage - 1) }} />
+          <PaginationLink previous onClick={() => { return changePage(activePage - 1) }} />
         </PaginationItem>,
       );
     }
@@ -88,41 +81,41 @@ class PaginationWrapper extends React.Component {
       );
     }
     return paginationItems;
-  }
+  }, [activePage, changePage]);
 
   /**
    * generate Elements of Pagination First Prev
    *  ex. << < 4 5 6 7 8 > >>, << < 1 2 3 4 > >>
    * this function set  numbers
    */
-  generatePaginations(activePage, paginationStart, maxViewPageNum) {
+  const generatePaginations = useCallback(() => {
     const paginationItems = [];
     for (let number = paginationStart; number <= maxViewPageNum; number++) {
       paginationItems.push(
         <PaginationItem key={`paginationItem-${number}`} active={number === activePage}>
-          <PaginationLink onClick={() => { return this.props.changePage(number) }}>
+          <PaginationLink onClick={() => { return changePage(number) }}>
             {number}
           </PaginationLink>
         </PaginationItem>,
       );
     }
     return paginationItems;
-  }
+  }, [activePage, changePage, paginationStart, maxViewPageNum]);
 
   /**
    * generate Elements of Pagination First Prev
    * ex.  <<   <   1  2  3  >  >>
    * this function set > & >>
    */
-  generateNextLast(activePage, totalPage) {
+  const generateNextLast = useCallback(() => {
     const paginationItems = [];
     if (totalPage !== activePage) {
       paginationItems.push(
         <PaginationItem key="painationItemNext">
-          <PaginationLink next onClick={() => { return this.props.changePage(activePage + 1) }} />
+          <PaginationLink next onClick={() => { return changePage(activePage + 1) }} />
         </PaginationItem>,
         <PaginationItem key="painationItemLast">
-          <PaginationLink last onClick={() => { return this.props.changePage(totalPage) }} />
+          <PaginationLink last onClick={() => { return changePage(totalPage) }} />
         </PaginationItem>,
       );
     }
@@ -137,13 +130,11 @@ class PaginationWrapper extends React.Component {
       );
     }
     return paginationItems;
+  }, [activePage, changePage, totalPage]);
 
-  }
-
-  getListClassName() {
+  const getListClassName = useMemo(() => {
     const listClassNames = [];
 
-    const { align } = this.props;
     if (align === 'center') {
       listClassNames.push('justify-content-center');
     }
@@ -152,38 +143,25 @@ class PaginationWrapper extends React.Component {
     }
 
     return listClassNames.join(' ');
-  }
-
-  render() {
-    const paginationItems = [];
+  }, [align]);
 
-    const activePage = this.state.activePage;
-    const totalPage = this.state.paginationNumbers.totalPage;
-    const paginationStart = this.state.paginationNumbers.paginationStart;
-    const maxViewPageNum = this.state.paginationNumbers.maxViewPageNum;
-    const firstPrevItems = this.generateFirstPrev(activePage);
-    paginationItems.push(firstPrevItems);
-    const paginations = this.generatePaginations(activePage, paginationStart, maxViewPageNum);
-    paginationItems.push(paginations);
-    const nextLastItems = this.generateNextLast(activePage, totalPage);
-    paginationItems.push(nextLastItems);
+  return (
+    <React.Fragment>
+      <Pagination size={props.size} listClassName={getListClassName}>
+        {generateFirstPrev()}
+        {generatePaginations()}
+        {generateNextLast()}
+      </Pagination>
+    </React.Fragment>
+  );
 
-    return (
-      <React.Fragment>
-        <Pagination size={this.props.size} listClassName={this.getListClassName()}>{paginationItems}</Pagination>
-      </React.Fragment>
-    );
-  }
-
-
-}
+});
 
 PaginationWrapper.propTypes = {
-
   activePage: PropTypes.number.isRequired,
   changePage: PropTypes.func.isRequired,
   totalItemsCount: PropTypes.number.isRequired,
-  pagingLimit: PropTypes.number.isRequired,
+  pagingLimit: PropTypes.number,
   align: PropTypes.string,
   size: PropTypes.string,
 };
@@ -191,6 +169,7 @@ PaginationWrapper.propTypes = {
 PaginationWrapper.defaultProps = {
   align: 'left',
   size: 'md',
+  pagingLimit: Infinity,
 };
 
-export default withTranslation()(PaginationWrapper);
+export default PaginationWrapper;

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

@@ -72,7 +72,7 @@ class SavePageControls extends React.Component {
     const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
 
     return (
-      <div className="d-flex align-items-center form-inline">
+      <div className="d-flex align-items-center form-inline flex-nowrap">
 
         {this.isAclEnabled
           && (

+ 3 - 2
src/client/js/components/User/SeenUserInfo.jsx

@@ -18,14 +18,14 @@ import FootstampIcon from '../FootstampIcon';
 const SeenUserInfo = (props) => {
   const [popoverOpen, setPopoverOpen] = useState(false);
   const toggle = () => setPopoverOpen(!popoverOpen);
-  const { pageContainer } = props;
+  const { pageContainer, disabled } = props;
   return (
     <div className="grw-seen-user-info">
       <Button id="po-seen-user" color="link" className="px-2">
         <span className="mr-1 footstamp-icon"><FootstampIcon /></span>
         <span className="seen-user-count">{pageContainer.state.countOfSeenUsers}</span>
       </Button>
-      <Popover placement="bottom" isOpen={popoverOpen} target="po-seen-user" toggle={toggle} trigger="legacy">
+      <Popover placement="bottom" isOpen={popoverOpen} target="po-seen-user" toggle={toggle} trigger="legacy" disabled={disabled}>
         <PopoverBody className="seen-user-popover">
           <div className="px-2 text-right user-list-content text-truncate text-muted">
             <UserPictureList users={pageContainer.state.seenUsers} />
@@ -38,6 +38,7 @@ const SeenUserInfo = (props) => {
 
 SeenUserInfo.propTypes = {
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  disabled: PropTypes.bool,
 };
 
 /**

+ 8 - 2
src/client/js/legacy/crowi.js

@@ -157,13 +157,19 @@ Crowi.highlightSelectedSection = function(hash) {
 window.addEventListener('load', (e) => {
   const { appContainer } = window;
   const pageContainer = appContainer.getContainer('PageContainer');
-  const { isEditable } = pageContainer;
+
+  // Do nothing if the page does not exist
+  // ex.) admin page,login page
+  if (pageContainer == null) {
+    return null;
+  }
+  const { isAbleToOpenPageEditor } = pageContainer;
 
   // hash on page
   if (window.location.hash) {
     const navigationContainer = appContainer.getContainer('NavigationContainer');
 
-    if (window.location.hash === '#edit' && isEditable) {
+    if (window.location.hash === '#edit' && isAbleToOpenPageEditor) {
       navigationContainer.setEditorMode('edit');
 
       // focus

+ 6 - 3
src/client/js/services/AppContainer.js

@@ -39,9 +39,6 @@ export default class AppContainer extends Container {
 
     this.config = JSON.parse(document.getElementById('growi-context-hydrate').textContent || '{}');
 
-    const isSharedPageElem = document.getElementById('is-shared-page');
-    this.isSharedUser = (isSharedPageElem != null);
-
     const userAgent = window.navigator.userAgent.toLowerCase();
     this.isMobile = /iphone|ipad|android/.test(userAgent);
 
@@ -50,6 +47,12 @@ export default class AppContainer extends Container {
       this.currentUser = JSON.parse(currentUserElem.textContent);
     }
 
+    const isSharedPageElem = document.getElementById('is-shared-page');
+
+    // check what kind of user
+    this.isGuestUser = this.currentUser == null;
+    this.isSharedUser = isSharedPageElem != null && this.currentUser == null;
+
     const userLocaleId = this.currentUser?.lang;
     this.i18n = i18nFactory(userLocaleId);
 

+ 102 - 35
src/client/js/services/PageContainer.js

@@ -60,12 +60,15 @@ export default class PageContainer extends Container {
       sumOfBookmarks: 0,
       createdAt: mainContent.getAttribute('data-page-created-at'),
       updatedAt: mainContent.getAttribute('data-page-updated-at'),
+
+      isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null,
       isTrashPage: isTrashPage(path),
-      isForbidden: JSON.parse(mainContent.getAttribute('data-page-is-forbidden')),
       isDeleted: JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
       isDeletable: JSON.parse(mainContent.getAttribute('data-page-is-deletable')),
       isNotCreatable: JSON.parse(mainContent.getAttribute('data-page-is-not-creatable')),
       isAbleToDeleteCompletely: JSON.parse(mainContent.getAttribute('data-page-is-able-to-delete-completely')),
+      isPageExist: mainContent.getAttribute('data-page-id') != null,
+
       pageUser: JSON.parse(mainContent.getAttribute('data-page-user')),
       tags: null,
       hasChildren: JSON.parse(mainContent.getAttribute('data-page-has-children')),
@@ -104,9 +107,10 @@ export default class PageContainer extends Container {
     this.initStateMarkdown();
     this.checkAndUpdateImageUrlCached(this.state.likerUsers);
 
-    const { currentUser } = this.appContainer;
+    const { isSharedUser } = this.appContainer;
+
     // see https://dev.growi.org/5fabddf8bbeb1a0048bcb9e9
-    const isAbleToGetAttachedInformationAboutPages = this.state.pageId != null || !(currentUser == null && this.state.isSharedPage);
+    const isAbleToGetAttachedInformationAboutPages = this.state.isPageExist && !isSharedUser;
 
     if (isAbleToGetAttachedInformationAboutPages) {
       this.retrieveSeenUsers();
@@ -143,16 +147,75 @@ export default class PageContainer extends Container {
   }
 
 
-  get isEditable() {
-    const { currentUser } = this.appContainer;
-    const {
-      isPageExist, isPageForbidden, isNotCreatable, isTrashPage,
-    } = this.state;
+  get isAbleToOpenPageEditor() {
+    const { isNotCreatable, isTrashPage } = this.state;
+    const { isGuestUser } = this.appContainer;
 
-    if (isPageExist && (currentUser != null) && !isPageForbidden && !isNotCreatable && !isTrashPage) {
-      return true;
-    }
-    return false;
+    return (!isNotCreatable && !isTrashPage && !isGuestUser);
+  }
+
+  /**
+   * whether to display reaction buttons
+   * ex.) like, bookmark
+   */
+  get isAbleToShowPageReactionButtons() {
+    const { isTrashPage, isPageExist } = this.state;
+    const { isSharedUser } = this.appContainer;
+
+    return (!isTrashPage && isPageExist && !isSharedUser);
+  }
+
+  /**
+   * whether to display tag labels
+   */
+  get isAbleToShowTagLabel() {
+    const { isUserPage } = this.state;
+    const { isSharedUser } = this.appContainer;
+
+    return (!isUserPage && !isSharedUser);
+  }
+
+  /**
+   * whether to display page management
+   * ex.) duplicate, rename
+   */
+  get isAbleToShowPageManagement() {
+    const { isPageExist, isPageInTrash } = this.state;
+    const { isSharedUser } = this.appContainer;
+
+    return (isPageExist && !isPageInTrash && !isSharedUser);
+  }
+
+  /**
+   * whether to threeStrandedButton
+   * ex.) view, edit, hackmd
+   */
+  get isAbleToShowThreeStrandedButton() {
+    const { isNotCreatable, isPageInTrash } = this.state;
+    const { isSharedUser, isGuestUser } = this.appContainer;
+
+    return (!isNotCreatable && !isPageInTrash && !isSharedUser && !isGuestUser);
+  }
+
+  /**
+   * whether to threeStrandedButton
+   * ex.) view, edit, hackmd
+   */
+  get isAbleToShowPageAuthors() {
+    const { isPageExist, isUserPage } = this.state;
+
+    return (isPageExist && !isUserPage);
+  }
+
+  /**
+   * whether to like button
+   * not displayed on user page
+   */
+  get isAbleToShowLikeButton() {
+    const { isUserPage } = this.state;
+    const { isSharedUser } = this.appContainer;
+
+    return (!isUserPage && !isSharedUser);
   }
 
   /**
@@ -229,12 +292,18 @@ export default class PageContainer extends Container {
     return this.appContainer.getContainer('NavigationContainer');
   }
 
-  setLatestRemotePageData(page, user) {
-    this.setState({
-      remoteRevisionId: page.revision._id,
-      revisionIdHackmdSynced: page.revisionHackmdSynced,
-      lastUpdateUsername: user.name,
-    });
+  setLatestRemotePageData(s2cMessagePageUpdated) {
+    const newState = {
+      remoteRevisionId: s2cMessagePageUpdated.revisionId,
+      revisionIdHackmdSynced: s2cMessagePageUpdated.revisionIdHackmdSynced,
+      lastUpdateUsername: s2cMessagePageUpdated.lastUpdateUsername,
+    };
+
+    if (s2cMessagePageUpdated.hasDraftOnHackmd != null) {
+      newState.hasDraftOnHackmd = s2cMessagePageUpdated.hasDraftOnHackmd;
+    }
+
+    this.setState(newState);
   }
 
   setTocHtml(tocHtml) {
@@ -483,9 +552,10 @@ export default class PageContainer extends Container {
 
       logger.debug({ obj: data }, `websocket on 'page:create'`); // eslint-disable-line quotes
 
-      // update PageStatusAlert
-      if (data.page.path === pageContainer.state.path) {
-        this.setLatestRemotePageData(data.page, data.user);
+      // update remote page data
+      const { s2cMessagePageUpdated } = data;
+      if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) {
+        pageContainer.setLatestRemotePageData(s2cMessagePageUpdated);
       }
     });
 
@@ -497,16 +567,10 @@ export default class PageContainer extends Container {
 
       logger.debug({ obj: data }, `websocket on 'page:update'`); // eslint-disable-line quotes
 
-      if (data.page.path === pageContainer.state.path) {
-        // update PageStatusAlert
-        pageContainer.setLatestRemotePageData(data.page, data.user);
-        // update remote data
-        const page = data.page;
-        pageContainer.setState({
-          remoteRevisionId: page.revision._id,
-          revisionIdHackmdSynced: page.revisionHackmdSynced,
-          hasDraftOnHackmd: page.hasDraftOnHackmd,
-        });
+      // update remote page data
+      const { s2cMessagePageUpdated } = data;
+      if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) {
+        pageContainer.setLatestRemotePageData(s2cMessagePageUpdated);
       }
     });
 
@@ -518,9 +582,10 @@ export default class PageContainer extends Container {
 
       logger.debug({ obj: data }, `websocket on 'page:delete'`); // eslint-disable-line quotes
 
-      // update PageStatusAlert
-      if (data.page.path === pageContainer.state.path) {
-        pageContainer.setLatestRemotePageData(data.page, data.user);
+      // update remote page data
+      const { s2cMessagePageUpdated } = data;
+      if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) {
+        pageContainer.setLatestRemotePageData(s2cMessagePageUpdated);
       }
     });
 
@@ -532,7 +597,9 @@ export default class PageContainer extends Container {
 
       logger.debug({ obj: data }, `websocket on 'page:editingWithHackmd'`); // eslint-disable-line quotes
 
-      if (data.page.path === pageContainer.state.path) {
+      // update isHackmdDraftUpdatingInRealtime
+      const { s2cMessagePageUpdated } = data;
+      if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) {
         pageContainer.setState({ isHackmdDraftUpdatingInRealtime: true });
       }
     });

+ 1 - 1
src/client/js/services/PageHistoryContainer.js

@@ -28,7 +28,7 @@ export default class PageHistoryContainer extends Container {
 
       totalPages: 0,
       activePage: 1,
-      pagingLimit: null,
+      pagingLimit: Infinity,
     };
 
     this.retrieveRevisions = this.retrieveRevisions.bind(this);

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

@@ -52,7 +52,7 @@
     }
 
     .auth-mechanism-configurations {
-      min-height: 300px;
+      min-height: 80vh;
     }
   }
 

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

@@ -10,7 +10,7 @@
   @include media-breakpoint-only(lg) {
     font-size: #{$basesize * 0.9};
   }
-  @include media-breakpoint-only(xl) {
+  @include media-breakpoint-up(xl) {
     font-size: $basesize;
   }
 }

+ 29 - 18
src/client/styles/scss/_on-edit.scss

@@ -11,8 +11,9 @@ body:not(.on-edit) {
 body.on-edit {
   overflow-y: hidden !important;
 
-  .container {
-    max-width: 100%;
+  .container-fluid {
+    padding-right: 15px;
+    padding-left: 15px;
   }
 
   .grw-navbar {
@@ -22,10 +23,12 @@ body.on-edit {
 
   // restrict height of subnav
   .grw-subnav {
-    max-height: $grw-subnav-max-height-on-edit;
+    height: $grw-subnav-height-on-edit;
+    min-height: unset;
+    padding-top: 0;
 
-    @include media-breakpoint-up(md) {
-      max-height: $grw-subnav-max-height-md-on-edit;
+    @include media-breakpoint-up(lg) {
+      height: $grw-subnav-height-lg-on-edit;
     }
   }
 
@@ -36,12 +39,12 @@ body.on-edit {
   }
 
   // calculate margin
-  $editor-margin-top: $grw-navbar-border-width + $grw-subnav-max-height-on-edit;
+  $editor-margin-top: $grw-navbar-border-width + $grw-subnav-height-on-edit;
   @include expand-editor($editor-margin-top);
 
-  @include media-breakpoint-up(md) {
+  @include media-breakpoint-up(lg) {
     // calculate margin
-    $editor-margin-top: $grw-navbar-border-width + $grw-subnav-max-height-md-on-edit;
+    $editor-margin-top: $grw-navbar-border-width + $grw-subnav-height-lg-on-edit;
     @include expand-editor($editor-margin-top);
   }
 
@@ -50,12 +53,6 @@ body.on-edit {
     display: block !important;
   }
 
-  .d-edit-sm-block {
-    @include media-breakpoint-up(sm) {
-      display: block !important;
-    }
-  }
-
   // hide unnecessary elements
   .d-edit-none {
     display: none !important;
@@ -82,10 +79,6 @@ body.on-edit {
   /*****************
    * Expand Editor
    *****************/
-  .container-fluid {
-    padding-bottom: 0;
-  }
-
   .grw-editor-navbar-bottom {
     height: $grw-editor-navbar-bottom-height;
 
@@ -126,6 +119,24 @@ body.on-edit {
   /*********************
    * Navigation styles
    */
+  .grw-subnav {
+    padding-bottom: 0;
+
+    h1 {
+      font-size: 16px;
+    }
+
+    .grw-drawer-toggler {
+      width: 38px;
+      height: 38px;
+      font-size: 18px;
+    }
+
+    .grw-taglabels-container {
+      margin-bottom: 0;
+    }
+  }
+
   // ellipsis .grw-page-path-hierarchical-link
   .grw-subnav-left-side {
     overflow: hidden;

+ 28 - 0
src/client/styles/scss/_override-bootstrap-variables.scss

@@ -25,6 +25,34 @@ $gray-900: darken($dark, 5%) !default;
 $grays: ("50": $gray-50) !default;
 $red: #ff0a54 !default;
 
+
+// Grid breakpoints
+//
+// Define the minimum dimensions at which your layout will change,
+// adapting to different screen sizes, for use in media queries.
+
+$grid-breakpoints: (
+  xs: 0,
+  sm: 576px,
+  md: 768px,
+  lg: 992px,
+  xl: 1200px,
+  2xl: 1480px
+);
+
+// Grid containers
+//
+// Define the maximum width of `.container` for different screen sizes.
+
+$container-max-widths: (
+  sm: 540px,
+  md: 720px,
+  lg: 960px,
+  xl: 1140px,
+  2xl: 1320px
+);
+
+
 //== Typography
 //
 //## Font, line-height, and color for body text, headings, and more.

+ 4 - 0
src/client/styles/scss/_subnav.scss

@@ -27,6 +27,10 @@
     line-height: 1.4em;
   }
 
+  .grw-taglabels-container {
+    margin-bottom: 0.5rem;
+  }
+
   .grw-page-path-nav {
     .separator {
       margin-right: 0.2em;

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

@@ -11,8 +11,8 @@ $grw-navbar-border-width: 3.3333px;
 
 $grw-subnav-min-height: 95px;
 $grw-subnav-min-height-md: 115px;
-$grw-subnav-max-height-on-edit: 95px;
-$grw-subnav-max-height-md-on-edit: 115px;
+$grw-subnav-height-on-edit: 95px;
+$grw-subnav-height-lg-on-edit: 50px;
 
 $grw-navbar-bottom-height: 48px;
 $grw-editor-navbar-bottom-height: 48px;

+ 8 - 6
src/client/styles/scss/_wiki.scss

@@ -12,11 +12,6 @@ div.body {
 
   font-size: 15px;
 
-  // override line-height except hljs and child of it.
-  :not(pre*) {
-    line-height: 1.8em;
-  }
-
   h1,
   h2,
   h3,
@@ -90,6 +85,10 @@ div.body {
     li {
       margin: 5px 0;
       line-height: 1.8em;
+
+      pre {
+        line-height: $line-height-base;
+      }
     }
 
     ul,
@@ -207,7 +206,10 @@ div.body {
       margin: 10px 0;
 
       li {
-        line-height: 1.1em;
+        line-height: $line-height-base;
+        pre {
+          line-height: $line-height-base;
+        }
       }
     }
 

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

@@ -39,6 +39,9 @@ $nav-tabs-link-active-border-color: $bordercolor-nav-tabs-active;
 @import 'reboot-bootstrap-nav';
 @import 'reboot-toastr-colors';
 
+// determine variables with bootstrap function (These variables can be used after importing bootstrap above)
+$color-modal-header: color-yiq($primary) !default;
+
 :not(pre) {
   > code {
     color: $color-inline-code;
@@ -286,10 +289,10 @@ ul.pagination {
   .modal-header {
     border-bottom-color: $border-color-theme;
     .modal-title {
-      color: color-yiq($primary);
+      color: $color-modal-header;
     }
     .close {
-      color: color-yiq($primary);
+      color: $color-modal-header;
       opacity: 0.5;
       &:hover {
         opacity: 0.9;
@@ -326,6 +329,7 @@ ul.pagination {
 .grw-custom-nav {
   .nav-item {
     .nav-link {
+      -webkit-appearance: none;
       color: $color-link;
       svg {
         fill: $color-link;

+ 2 - 1
src/client/styles/scss/theme/antarctic.scss

@@ -98,7 +98,8 @@ html[dark] {
   $color-editor-icons: $color-global;
 
   // Border colors
-  $border-color-theme: $gray-300; // former: `$navbar-border: $gray-300;`
+  $border-color-theme: $gray-400;
+  $border-color-global: $gray-400;
   $bordercolor-inline-code: #ccc8c8; // optional
 
   // Dropdown colors

+ 1 - 0
src/client/styles/scss/theme/christmas.scss

@@ -51,6 +51,7 @@ html[dark] {
   $color-link-hover: lighten($color-link, 10%);
   $color-link-nabvar: $color-reversal;
   $color-inline-code: #c7254e; // optional
+  $color-modal-header: $themelight;
 
   // Table colors
   $border-color-table: $gray-400; // optional

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

@@ -403,7 +403,7 @@ module.exports = function(crowi) {
       throw new Error('User data is not valid');
     }
 
-    const added = this.seenUsers.addToSet(userData);
+    const added = this.seenUsers.addToSet(userData._id);
     const saved = await this.save();
 
     debug('seenUsers updated!', added);

+ 48 - 0
src/server/models/serializers/page-serializer.js

@@ -0,0 +1,48 @@
+const { serializeUserSecurely } = require('./user-serializer');
+
+function depopulate(page, attributeName) {
+  // revert the ObjectID
+  if (page[attributeName] != null && page[attributeName]._id != null) {
+    page[attributeName] = page[attributeName]._id;
+  }
+}
+
+function depopulateRevisions(page) {
+  depopulate(page, 'revision');
+  depopulate(page, 'revisionHackmdSynced');
+}
+
+function serializeInsecureUserAttributes(page) {
+  if (page.lastUpdateUser != null && page.lastUpdateUser._id != null) {
+    page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
+  }
+  if (page.creator != null && page.creator._id != null) {
+    page.creator = serializeUserSecurely(page.creator);
+  }
+  if (page.revision != null && page.revision.author != null && page.revision.author._id != null) {
+    page.revision.author = serializeUserSecurely(page.revision.author);
+  }
+  return page;
+}
+
+function serializePageSecurely(page, shouldDepopulateRevisions = false) {
+  let serialized = page;
+
+  // invoke toObject if page is a model instance
+  if (page.toObject != null) {
+    serialized = page.toObject();
+  }
+
+  // optional depopulation
+  if (shouldDepopulateRevisions) {
+    depopulateRevisions(serialized);
+  }
+
+  serializeInsecureUserAttributes(serialized);
+
+  return serialized;
+}
+
+module.exports = {
+  serializePageSecurely,
+};

+ 27 - 0
src/server/models/serializers/user-serializer.js

@@ -0,0 +1,27 @@
+function omitInsecureAttributes(user) {
+  // omit password
+  delete user.password;
+  // omit email
+  if (!user.isEmailPublished) {
+    delete user.email;
+  }
+  return user;
+}
+
+function serializeUserSecurely(user) {
+  let serialized = user;
+
+  // invoke toObject if page is a model instance
+  if (user.toObject != null) {
+    serialized = user.toObject();
+  }
+
+  omitInsecureAttributes(serialized);
+
+  return serialized;
+}
+
+module.exports = {
+  omitInsecureAttributes,
+  serializeUserSecurely,
+};

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

@@ -13,6 +13,8 @@ const crypto = require('crypto');
 
 const { listLocaleIds, migrateDeprecatedLocaleId } = require('@commons/util/locale-utils');
 
+const { omitInsecureAttributes } = require('./serializers/user-serializer');
+
 module.exports = function(crowi) {
   const STATUS_REGISTERED = 1;
   const STATUS_ACTIVE = 2;
@@ -65,13 +67,7 @@ module.exports = function(crowi) {
   }, {
     toObject: {
       transform: (doc, ret, opt) => {
-        // omit password
-        delete ret.password;
-        // omit email
-        if (!doc.isEmailPublished) {
-          delete ret.email;
-        }
-        return ret;
+        return omitInsecureAttributes(ret);
       },
     },
   });

+ 30 - 0
src/server/models/vo/s2c-message.js

@@ -0,0 +1,30 @@
+const { serializePageSecurely } = require('../serializers/page-serializer');
+
+/**
+ * Server-to-client message VO
+ */
+class S2cMessagePageUpdated {
+
+
+  constructor(page, user) {
+    const serializedPage = serializePageSecurely(page, true);
+
+    const {
+      _id, revision, revisionHackmdSynced, hasDraftOnHackmd,
+    } = serializedPage;
+
+    this.pageId = _id;
+    this.revisionId = revision;
+    this.revisionIdHackmdSynced = revisionHackmdSynced;
+    this.hasDraftOnHackmd = hasDraftOnHackmd;
+
+    if (user != null) {
+      this.lastUpdateUsername = user.name;
+    }
+  }
+
+}
+
+module.exports = {
+  S2cMessagePageUpdated,
+};

+ 1 - 1
src/server/routes/apiv3/attachment.js

@@ -17,7 +17,7 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
 
 module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
-  const loginRequired = require('../../middlewares/login-required')(crowi);
+  const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const Page = crowi.model('Page');
   const User = crowi.model('User');
   const Attachment = crowi.model('Attachment');

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

@@ -151,7 +151,7 @@ module.exports = function(crowi, app) {
   app.get('/tags'                     , loginRequired, tag.showPage);
   app.get('/_api/tags.list'           , accessTokenParser, loginRequired, tag.api.list);
   app.get('/_api/tags.search'         , accessTokenParser, loginRequired, tag.api.search);
-  app.post('/_api/tags.update'        , accessTokenParser, loginRequired, tag.api.update);
+  app.post('/_api/tags.update'        , accessTokenParser, loginRequiredStrictly, tag.api.update);
   app.get('/_api/comments.get'        , accessTokenParser , loginRequired , comment.api.get);
   app.post('/_api/comments.add'       , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , csrf, comment.api.add);
   app.post('/_api/comments.update'    , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , csrf, comment.api.update);

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

@@ -1,3 +1,5 @@
+const { serializePageSecurely } = require('../models/serializers/page-serializer');
+
 /**
  * @swagger
  *  tags:
@@ -143,7 +145,6 @@ module.exports = function(crowi, app) {
   const { slackNotificationService, configManager } = crowi;
   const interceptorManager = crowi.getInterceptorManager();
   const globalNotificationService = crowi.getGlobalNotificationService();
-  const pageService = crowi.pageService;
 
   const actions = {};
 
@@ -780,7 +781,7 @@ module.exports = function(crowi, app) {
       savedTags = await PageTagRelation.listTagNamesByPage(createdPage.id);
     }
 
-    const result = { page: pageService.serializeToObj(createdPage), tags: savedTags };
+    const result = { page: serializePageSecurely(createdPage), tags: savedTags };
     res.json(ApiResponse.success(result));
 
     // update scopes for descendants
@@ -909,7 +910,7 @@ module.exports = function(crowi, app) {
       savedTags = await PageTagRelation.listTagNamesByPage(pageId);
     }
 
-    const result = { page: pageService.serializeToObj(page), tags: savedTags };
+    const result = { page: serializePageSecurely(page), tags: savedTags };
     res.json(ApiResponse.success(result));
 
     // update scopes for descendants
@@ -1009,7 +1010,7 @@ module.exports = function(crowi, app) {
     }
 
     const result = {};
-    result.page = page; // TODO consider to use serializeToObj method -- 2018.08.06 Yuki Takei
+    result.page = page; // TODO consider to use serializePageSecurely method -- 2018.08.06 Yuki Takei
 
     return res.json(ApiResponse.success(result));
   };
@@ -1240,7 +1241,7 @@ module.exports = function(crowi, app) {
 
     debug('Page deleted', page.path);
     const result = {};
-    result.page = page; // TODO consider to use serializeToObj method -- 2018.08.06 Yuki Takei
+    result.page = page; // TODO consider to use serializePageSecurely method -- 2018.08.06 Yuki Takei
 
     res.json(ApiResponse.success(result));
 
@@ -1287,7 +1288,7 @@ module.exports = function(crowi, app) {
     }
 
     const result = {};
-    result.page = page; // TODO consider to use serializeToObj method -- 2018.08.06 Yuki Takei
+    result.page = page; // TODO consider to use serializePageSecurely method -- 2018.08.06 Yuki Takei
 
     return res.json(ApiResponse.success(result));
   };
@@ -1398,7 +1399,7 @@ module.exports = function(crowi, app) {
     }
 
     const result = {};
-    result.page = page; // TODO consider to use serializeToObj method -- 2018.08.06 Yuki Takei
+    result.page = page; // TODO consider to use serializePageSecurely method -- 2018.08.06 Yuki Takei
 
     res.json(ApiResponse.success(result));
 

+ 0 - 20
src/server/service/page.js

@@ -4,26 +4,6 @@ class PageService {
     this.crowi = crowi;
   }
 
-  serializeToObj(page) {
-    const { User } = this.crowi.models;
-
-    const returnObj = page.toObject();
-
-    // set the ObjectID to revisionHackmdSynced
-    if (page.revisionHackmdSynced != null && page.revisionHackmdSynced._id != null) {
-      returnObj.revisionHackmdSynced = page.revisionHackmdSynced._id;
-    }
-
-    if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
-      returnObj.lastUpdateUser = page.lastUpdateUser.toObject();
-    }
-    if (page.creator != null && page.creator instanceof User) {
-      returnObj.creator = page.creator.toObject();
-    }
-
-    return returnObj;
-  }
-
   async deleteCompletely(pageId, pagePath) {
     // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
     const Bookmark = this.crowi.model('Bookmark');

+ 17 - 16
src/server/service/system-events/sync-page-status.js

@@ -1,6 +1,7 @@
 const logger = require('@alias/logger')('growi:service:system-events:SyncPageStatusService');
 
 const S2sMessage = require('../../models/vo/s2s-message');
+const { S2cMessagePageUpdated } = require('../../models/vo/s2c-message');
 const S2sMessageHandlable = require('../s2s-messaging/handlable');
 
 /**
@@ -46,20 +47,20 @@ class SyncPageStatusService extends S2sMessageHandlable {
    * @inheritdoc
    */
   async handleS2sMessage(s2sMessage) {
-    const { socketIoEventName, page, user } = s2sMessage;
+    const { socketIoEventName, s2cMessageBody } = s2sMessage;
     const { socketIoService } = this;
 
     // emit the updated information to clients
     if (socketIoService.isInitialized) {
-      socketIoService.getDefaultSocket().emit(socketIoEventName, { page, user });
+      socketIoService.getDefaultSocket().emit(socketIoEventName, s2cMessageBody);
     }
   }
 
-  async publishToOtherServers(socketIoEventName, page, user) {
+  async publishToOtherServers(socketIoEventName, s2cMessageBody) {
     const { s2sMessagingService } = this;
 
     if (s2sMessagingService != null) {
-      const s2sMessage = new S2sMessage('pageStatusUpdated', { socketIoEventName, page, user });
+      const s2sMessage = new S2sMessage('pageStatusUpdated', { socketIoEventName, s2cMessageBody });
 
       try {
         await s2sMessagingService.publish(s2sMessage);
@@ -72,36 +73,36 @@ class SyncPageStatusService extends S2sMessageHandlable {
 
   initSystemEventListeners() {
     const { socketIoService } = this;
-    const { pageService } = this.crowi;
 
     // register events
     this.emitter.on('create', (page, user, socketClientId) => {
       logger.debug('\'create\' event emitted.');
 
-      page = pageService.serializeToObj(page); // eslint-disable-line no-param-reassign
-      socketIoService.getDefaultSocket().emit('page:create', { page, user, socketClientId });
+      const s2cMessagePageUpdated = new S2cMessagePageUpdated(page, user);
+      socketIoService.getDefaultSocket().emit('page:create', { s2cMessagePageUpdated, socketClientId });
 
-      this.publishToOtherServers('page:create', page, user);
+      this.publishToOtherServers('page:create', { s2cMessagePageUpdated });
     });
     this.emitter.on('update', (page, user, socketClientId) => {
       logger.debug('\'update\' event emitted.');
 
-      page = pageService.serializeToObj(page); // eslint-disable-line no-param-reassign
-      socketIoService.getDefaultSocket().emit('page:update', { page, user, socketClientId });
+      const s2cMessagePageUpdated = new S2cMessagePageUpdated(page, user);
+      socketIoService.getDefaultSocket().emit('page:update', { s2cMessagePageUpdated, socketClientId });
 
-      this.publishToOtherServers('page:update', page, user);
+      this.publishToOtherServers('page:update', { s2cMessagePageUpdated });
     });
     this.emitter.on('delete', (page, user, socketClientId) => {
       logger.debug('\'delete\' event emitted.');
 
-      page = pageService.serializeToObj(page); // eslint-disable-line no-param-reassign
-      socketIoService.getDefaultSocket().emit('page:delete', { page, user, socketClientId });
+      const s2cMessagePageUpdated = new S2cMessagePageUpdated(page, user);
+      socketIoService.getDefaultSocket().emit('page:delete', { s2cMessagePageUpdated, socketClientId });
 
-      this.publishToOtherServers('page:delete', page, user);
+      this.publishToOtherServers('page:delete', { s2cMessagePageUpdated });
     });
     this.emitter.on('saveOnHackmd', (page) => {
-      socketIoService.getDefaultSocket().emit('page:editingWithHackmd', { page });
-      this.publishToOtherServers('page:editingWithHackmd', page);
+      const s2cMessagePageUpdated = new S2cMessagePageUpdated(page);
+      socketIoService.getDefaultSocket().emit('page:editingWithHackmd', { s2cMessagePageUpdated });
+      this.publishToOtherServers('page:editingWithHackmd', { s2cMessagePageUpdated });
     });
   }
 

+ 8 - 2
src/server/views/layout-growi/forbidden.html

@@ -8,9 +8,15 @@
 {% endblock %}
 
 {% block content_main %}
-  <div class="container-lg">
-    {% include '../widget/forbidden_content.html' %}
+  <div
+    id="content-main"
+    class="content-main page-list"
+    data-path="{{ encodeURI(path) }}"
+    data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
+    data-page-is-not-creatable="true"
+    >
   </div>
+  <div class="container-lg" id="forbidden-page"></div>
 {% endblock %}
 
 {% block body_end %}

+ 0 - 46
src/server/views/widget/forbidden_content.html

@@ -1,46 +0,0 @@
-<div class="row not-found-message-row mb-4">
-  <div class="col-lg-12">
-    <h2 class="text-muted">
-      <i class="icon-ban" aria-hidden="true"></i>
-      Forbidden
-    </h2>
-  </div>
-</div>
-
-<div id="content-main" class="content-main page-list"
-  data-path="{{ encodeURI(path) }}"
-  data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
-  data-page-is-forbidden="true"
-  data-page-is-not-creatable="true"
-  >
-
-  <div class="row row-alerts d-edit-none">
-    <div class="col-sm-12">
-        <p class="alert alert-primary py-3 px-4">
-          <i class="icon-fw icon-lock" aria-hidden="true"></i> Browsing of this page is restricted
-        </p>
-    </div>
-  </div>
-
-  <ul class="nav nav-tabs d-print-none" role="tablist">
-    <li class="nav-item grw-nav-main-left-tab">
-      <a class="nav-link active">
-        <i class="icon-notebook"></i> List
-      </a>
-    </li>
-  </ul>
-
-  <div class="tab-content">
-    {# list view #}
-    <div class="pt-2 active tab-pane page-list-container">
-      {% if pages.length == 0 %}
-        <div class="mt-2">
-          There are no pages under <strong>{{ path | preventXss }}</strong>.
-        </div>
-      {% endif  %}
-
-      {% include '../widget/page_list.html' with { pages: pages, pager: pager, viewConfig: viewConfig } %}
-    </div>
-
-  </div>
-</div>

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

@@ -14,7 +14,6 @@
   data-page-grant-group-name="{{ grantedGroupName }}"
   data-page-is-liked="{% if user %}{{ page.isLiked(user) }}{% else %}false{% endif %}"
   data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
-  data-page-is-forbidden="false"
   data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
   data-page-is-deletable="{% if isDeletablePage() %}true{% else %}false{% endif %}"
   data-page-is-not-creatable="false"