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

Merge branch 'master' into fix/gw-4409-adjust-color-of-grw-custom-nab-on-hover

# Conflicts:
#	src/client/styles/scss/theme/_apply-colors.scss
yusuketk 5 лет назад
Родитель
Сommit
a3354f4141
46 измененных файлов с 465 добавлено и 289 удалено
  1. 6 5
      resource/locales/en_US/admin/admin.json
  2. 2 1
      resource/locales/ja_JP/admin/admin.json
  3. 6 5
      resource/locales/zh_CN/admin/admin.json
  4. 5 3
      src/client/js/app.jsx
  5. 1 1
      src/client/js/components/Admin/Customize/ThemeColorBox.jsx
  6. 18 10
      src/client/js/components/Admin/ManageExternalAccount.jsx
  7. 54 0
      src/client/js/components/ForbiddenPage.jsx
  8. 1 1
      src/client/js/components/Navbar/DrawerToggler.jsx
  9. 26 39
      src/client/js/components/Navbar/GrowiSubNavigation.jsx
  10. 4 4
      src/client/js/components/Page/NotFoundAlert.jsx
  11. 23 26
      src/client/js/components/Page/RenderTagLabels.jsx
  12. 0 5
      src/client/js/components/Page/RevisionPathControls.jsx
  13. 3 0
      src/client/js/components/Page/TagLabels.jsx
  14. 1 2
      src/client/js/components/PageAccessories.jsx
  15. 6 6
      src/client/js/components/PageAccessoriesModal.jsx
  16. 4 4
      src/client/js/components/PageAccessoriesModalControl.jsx
  17. 1 1
      src/client/js/components/PageAttachment.jsx
  18. 5 5
      src/client/js/components/PageEditor/EditorNavbarBottom.jsx
  19. 3 3
      src/client/js/components/PageEditor/OptionsSelector.jsx
  20. 1 1
      src/client/js/components/PageList.jsx
  21. 6 2
      src/client/js/components/PaginationWrapper.jsx
  22. 1 1
      src/client/js/components/SavePageControls.jsx
  23. 6 0
      src/client/js/legacy/crowi.js
  24. 4 1
      src/client/js/services/AppContainer.js
  25. 88 35
      src/client/js/services/PageContainer.js
  26. 1 1
      src/client/js/services/PageHistoryContainer.js
  27. 1 1
      src/client/styles/scss/_admin.scss
  28. 1 1
      src/client/styles/scss/_mixins.scss
  29. 29 18
      src/client/styles/scss/_on-edit.scss
  30. 4 0
      src/client/styles/scss/_subnav.scss
  31. 2 2
      src/client/styles/scss/_variables.scss
  32. 7 3
      src/client/styles/scss/theme/_apply-colors.scss
  33. 1 0
      src/client/styles/scss/theme/christmas.scss
  34. 1 1
      src/server/models/page.js
  35. 48 0
      src/server/models/serializers/page-serializer.js
  36. 27 0
      src/server/models/serializers/user-serializer.js
  37. 3 7
      src/server/models/user.js
  38. 30 0
      src/server/models/vo/s2c-message.js
  39. 1 1
      src/server/routes/apiv3/attachment.js
  40. 1 1
      src/server/routes/index.js
  41. 8 7
      src/server/routes/page.js
  42. 0 20
      src/server/service/page.js
  43. 17 16
      src/server/service/system-events/sync-page-status.js
  44. 8 2
      src/server/views/layout-growi/forbidden.html
  45. 0 46
      src/server/views/widget/forbidden_content.html
  46. 0 1
      src/server/views/widget/page_content.html

+ 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>
     );

+ 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);

+ 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"

+ 26 - 39
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-3 pr-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">
             { pageContainer.isAbleToShowPageReactionButtons && <PageReactionButtons appContainer={appContainer} pageContainer={pageContainer} /> }
-            { !isPageNotFound && !isPageForbidden && <PageManagement isCompactMode={isCompactMode} /> }
+            { 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>

+ 1 - 2
src/client/js/components/PageAccessories.jsx

@@ -10,8 +10,7 @@ import PageAccessoriesContainer from '../services/PageAccessoriesContainer';
 
 const PageAccessories = (props) => {
   const { appContainer, pageAccessoriesContainer } = props;
-  const isGuestUser = appContainer.currentUser == null;
-  const isSharedUser = appContainer.isSharedUser;
+  const { isGuestUser, isSharedUser } = appContainer;
 
   return (
     <>

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

@@ -48,7 +48,7 @@ const PageAccessoriesModal = (props) => {
         Icon: HistoryIcon,
         i18n: t('History'),
         index: 2,
-        isLinkEnabled: v => !isSharedUser,
+        isLinkEnabled: v => !isGuestUser && !isSharedUser,
       },
       attachment: {
         Icon: AttachmentIcon,
@@ -59,7 +59,7 @@ const PageAccessoriesModal = (props) => {
         Icon: ShareLinkIcon,
         i18n: t('share_links.share_link_management'),
         index: 4,
-        isLinkEnabled: v => !isGuestUser || !isSharedUser,
+        isLinkEnabled: v => !isGuestUser && !isSharedUser,
       },
     };
   }, [t, isGuestUser, isSharedUser]);
@@ -109,11 +109,11 @@ 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>

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

@@ -1,4 +1,4 @@
-import React, { useMemo } from 'react';
+import React, { Fragment, useMemo } from 'react';
 import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
@@ -35,7 +35,7 @@ const PageAccessoriesModalControl = (props) => {
       {
         name: 'pageHistory',
         Icon: <HistoryIcon />,
-        disabled: isSharedUser,
+        disabled: isGuestUser || isSharedUser,
       },
       {
         name: 'attachment',
@@ -54,7 +54,7 @@ const PageAccessoriesModalControl = (props) => {
     <div className="grw-page-accessories-control d-flex align-items-center justify-content-between pb-1">
       {accessoriesBtnList.map((accessory) => {
         return (
-          <>
+          <Fragment key={accessory.name}>
             <div id={`shareLink-btn-wrapper-for-tooltip-for-${accessory.name}`}>
               <button
                 type="button"
@@ -69,7 +69,7 @@ const PageAccessoriesModalControl = (props) => {
                 {t('Not available for guest')}
               </UncontrolledTooltip>
             )}
-          </>
+          </Fragment>
         );
       })}
       <div className="d-flex align-items-center">

+ 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,

+ 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);

+ 6 - 2
src/client/js/components/PaginationWrapper.jsx

@@ -21,8 +21,11 @@ const PaginationWrapper = React.memo((props) => {
    * various numbers used to generate pagination dom
    */
   const paginationNumbers = useMemo(() => {
+    // avoid using null
+    const limit = pagingLimit || Infinity;
+
     // calc totalPageNumber
-    const totalPage = Math.floor(totalItemsCount / pagingLimit) + (totalItemsCount % pagingLimit === 0 ? 0 : 1);
+    const totalPage = Math.floor(totalItemsCount / limit) + (totalItemsCount % limit === 0 ? 0 : 1);
 
     let paginationStart = activePage - 2;
     let maxViewPageNum = activePage + 2;
@@ -158,7 +161,7 @@ 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,
 };
@@ -166,6 +169,7 @@ PaginationWrapper.propTypes = {
 PaginationWrapper.defaultProps = {
   align: 'left',
   size: 'md',
+  pagingLimit: Infinity,
 };
 
 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
           && (

+ 6 - 0
src/client/js/legacy/crowi.js

@@ -157,6 +157,12 @@ Crowi.highlightSelectedSection = function(hash) {
 window.addEventListener('load', (e) => {
   const { appContainer } = window;
   const pageContainer = appContainer.getContainer('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

+ 4 - 1
src/client/js/services/AppContainer.js

@@ -48,7 +48,10 @@ export default class AppContainer extends Container {
     }
 
     const isSharedPageElem = document.getElementById('is-shared-page');
-    this.isSharedUser = this.currentUser == null && isSharedPageElem != null;
+
+    // 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);

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

@@ -61,8 +61,8 @@ export default class PageContainer extends Container {
       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')),
@@ -107,14 +107,10 @@ export default class PageContainer extends Container {
     this.initStateMarkdown();
     this.checkAndUpdateImageUrlCached(this.state.likerUsers);
 
-    const { currentUser } = this.appContainer;
-
-    // check what kind of user
-    this.state.isGuestUser = currentUser == null;
-    this.state.isSharedUser = this.state.shareLinkId != null && currentUser == null;
+    const { isSharedUser } = this.appContainer;
 
     // see https://dev.growi.org/5fabddf8bbeb1a0048bcb9e9
-    const isAbleToGetAttachedInformationAboutPages = this.state.isPageExist && !this.state.isSharedUser;
+    const isAbleToGetAttachedInformationAboutPages = this.state.isPageExist && !isSharedUser;
 
     if (isAbleToGetAttachedInformationAboutPages) {
       this.retrieveSeenUsers();
@@ -152,11 +148,10 @@ export default class PageContainer extends Container {
 
 
   get isAbleToOpenPageEditor() {
-    const {
-      isGuestUser, isPageForbidden, isNotCreatable, isTrashPage,
-    } = this.state;
+    const { isNotCreatable, isTrashPage } = this.state;
+    const { isGuestUser } = this.appContainer;
 
-    return (!isGuestUser && !isPageForbidden && !isNotCreatable && !isTrashPage);
+    return (!isNotCreatable && !isTrashPage && !isGuestUser);
   }
 
   /**
@@ -164,11 +159,65 @@ export default class PageContainer extends Container {
    * ex.) like, bookmark
    */
   get isAbleToShowPageReactionButtons() {
-    const { isTrashPage, isPageExist, isSharedUser } = this.state;
+    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);
+  }
+
   /**
    * initialize state for markdown data
    */
@@ -243,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) {
@@ -497,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);
       }
     });
 
@@ -511,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);
       }
     });
 
@@ -532,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);
       }
     });
 
@@ -546,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;

+ 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;

+ 7 - 3
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;
@@ -329,7 +332,8 @@ ul.pagination {
     &:focus {
       background-color: rgba($color-link, 0.08);
     }
-    s .nav-link {
+    .nav-link {
+      -webkit-appearance: none;
       color: $color-link;
       svg {
         fill: $color-link;

+ 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"