Taichi Masuyama пре 3 година
родитељ
комит
f72929d19b

+ 25 - 0
packages/app/resource/locales/en_US/translation.json

@@ -1077,5 +1077,30 @@
     "select_group": "Select group",
     "select_group": "Select group",
     "belonging_to_no_group": "Could not find the groups you belong to.",
     "belonging_to_no_group": "Could not find the groups you belong to.",
     "manage_user_groups": "Manage user groups"
     "manage_user_groups": "Manage user groups"
+  },
+  "fix_page_grant": {
+    "modal": {
+      "no_grant_available": "The list of selectable permissions could not be found. Please modify the permissions on the parent page first and try again.",
+      "need_to_fix_grant": "The permissions associated with this page must be modified in order to use the functionality correctly. <br> Please select from the options below to make the change.",
+      "grant_label": {
+        "isForbidden": "Authority not allowed to view",
+        "currentPageGrantLabel": "Authorization for this page: ",
+        "parentPageGrantLabel": "Authority of parent page: ",
+        "docLink": "For more information on modifying permissions, please refer to <a href='https://docs.growi.org/ja/admin-guide/admin-cookbook/integrate-with-hackmd.html'>こちらのリンク</a>"
+      },
+      "radio_btn": {
+        "restrected": "Only those who know the link",
+        "only_me": "only to oneself",
+        "grant_group": "Only specific groups"
+      },
+      "select_group_default_text": "Select Group",
+      "alert_message_select_group": "No group selected",
+      "btn_label": "Conversion",
+      "title": "Modify authority"
+    },
+    "alert": {
+      "description": "You need to modify the permission settings for this page.",
+      "btn_label": "Revision"
+    }
   }
   }
 }
 }

+ 25 - 0
packages/app/resource/locales/ja_JP/translation.json

@@ -1070,5 +1070,30 @@
     "select_group": "グループを選ぶ",
     "select_group": "グループを選ぶ",
     "belonging_to_no_group": "所属しているグループが見つかりませんでした。",
     "belonging_to_no_group": "所属しているグループが見つかりませんでした。",
     "manage_user_groups": "グループ管理"
     "manage_user_groups": "グループ管理"
+  },
+  "fix_page_grant": {
+    "modal": {
+      "no_grant_available": "選択可能な権限のリストが見つかりませんでした。まず親ページの権限を修正したのちに再試行してください。",
+      "need_to_fix_grant": "正しく機能を使用するためにはこのページに紐づく権限を修正する必要があります。 <br> 下記の選択肢から選んで変更してください。",
+      "grant_label": {
+        "isForbidden": "権限の閲覧が許可されていません",
+        "currentPageGrantLabel": "このページの権限: ",
+        "parentPageGrantLabel": "親のページの権限: ",
+        "docLink": "権限の修正についての詳細は<a href='https://docs.growi.org/ja/admin-guide/admin-cookbook/integrate-with-hackmd.html'>こちらのリンク</a>を参照してください"
+      },
+      "radio_btn": {
+        "restrected": "リンクを知っている人のみ",
+        "only_me": "自分のみ",
+        "grant_group": "特定グループのみ"
+      },
+      "select_group_default_text": "グループを選択",
+      "alert_message_select_group": "グループが選択されていません",
+      "btn_label": "変換",
+      "title": "権限を修正"
+    },
+    "alert": {
+      "description": "このページの権限設定を修正する必要があります。",
+      "btn_label": "修正"
+    }
   }
   }
 }
 }

+ 25 - 0
packages/app/resource/locales/zh_CN/translation.json

@@ -1080,5 +1080,30 @@
     "select_group": "选择组别",
     "select_group": "选择组别",
     "belonging_to_no_group": "无法找到你所属的团体。",
     "belonging_to_no_group": "无法找到你所属的团体。",
     "manage_user_groups": "管理用户组"
     "manage_user_groups": "管理用户组"
+  },
+  "fix_page_grant": {
+    "modal": {
+      "no_grant_available": "无法找到可选择的权限列表。 请先修改父页的权限,然后再试一次。",
+      "need_to_fix_grant": "为了正确使用该功能,需要修改与该页面相关的权限。 <br> 请从以下选项中选择进行更改。",
+      "grant_label": {
+        "isForbidden": "无权查看的机构",
+        "currentPageGrantLabel": "本页的权限: ",
+        "parentPageGrantLabel": "父页的权限: ",
+        "docLink": "关于修改授权的更多信息,请参见此<a href='https://docs.growi.org/ja/admin-guide/admin-cookbook/integrate-with-hackmd.html'>此链接</a>"
+      },
+      "radio_btn": {
+        "restrected": "只有那些知道链接的人",
+        "only_me": "只对自己说",
+        "grant_group": "仅限特定群体"
+      },
+      "select_group_default_text": "选择组别",
+      "alert_message_select_group": "未选择组别",
+      "btn_label": "蜕变",
+      "title": "修改后的授权书"
+    },
+    "alert": {
+      "description": "本页的授权设置需要修改。",
+      "btn_label": "修改"
+    }
   }
   }
 }
 }

+ 3 - 0
packages/app/src/client/app.jsx

@@ -33,6 +33,7 @@ import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationS
 import NotFoundPage from '../components/NotFoundPage';
 import NotFoundPage from '../components/NotFoundPage';
 import Page from '../components/Page';
 import Page from '../components/Page';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
+import FixPageGrantAlert from '../components/Page/FixPageGrantAlert';
 import NotFoundAlert from '../components/Page/NotFoundAlert';
 import NotFoundAlert from '../components/Page/NotFoundAlert';
 import RedirectedAlert from '../components/Page/RedirectedAlert';
 import RedirectedAlert from '../components/Page/RedirectedAlert';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
@@ -98,6 +99,8 @@ Object.assign(componentMappings, {
 
 
   'trash-page-alert': <TrashPageAlert />,
   'trash-page-alert': <TrashPageAlert />,
 
 
+  'fix-page-grant-alert': <FixPageGrantAlert />,
+
   'trash-page-list-container': <TrashPageList />,
   'trash-page-list-container': <TrashPageList />,
 
 
   'not-found-page': <NotFoundPage />,
   'not-found-page': <NotFoundPage />,

+ 3 - 1
packages/app/src/client/services/ContextExtractor.tsx

@@ -16,7 +16,7 @@ import {
   useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
-  useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
+  useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
   useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
   useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
   useDefaultIndentSize, useIsIndentSizeForced,
   useDefaultIndentSize, useIsIndentSizeForced,
 } from '../../stores/context';
 } from '../../stores/context';
@@ -71,6 +71,7 @@ const ContextExtractorOnce: FC = () => {
   const isForbidden = forbiddenContent != null;
   const isForbidden = forbiddenContent != null;
   const pageUser = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
   const pageUser = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
   const hasChildren = JSON.parse(mainContent?.getAttribute('data-page-has-children') || jsonNull);
   const hasChildren = JSON.parse(mainContent?.getAttribute('data-page-has-children') || jsonNull);
+  const hasParent = JSON.parse(mainContent?.getAttribute('data-has-parent') || jsonNull);
   const templateTagData = mainContent?.getAttribute('data-template-tags') || null;
   const templateTagData = mainContent?.getAttribute('data-template-tags') || null;
   const shareLinksNumber = mainContent?.getAttribute('data-share-links-number');
   const shareLinksNumber = mainContent?.getAttribute('data-share-links-number');
   const shareLinkId = JSON.parse(mainContent?.getAttribute('data-share-link-id') || jsonNull);
   const shareLinkId = JSON.parse(mainContent?.getAttribute('data-share-link-id') || jsonNull);
@@ -144,6 +145,7 @@ const ContextExtractorOnce: FC = () => {
   useNotFoundTargetPathOrId(notFoundTargetPathOrId);
   useNotFoundTargetPathOrId(notFoundTargetPathOrId);
   useIsNotFoundPermalink(isNotFoundPermalink);
   useIsNotFoundPermalink(isNotFoundPermalink);
   useIsSearchPage(isSearchPage);
   useIsSearchPage(isSearchPage);
+  useHasParent(hasParent);
 
 
   // Navigation
   // Navigation
   usePreferDrawerModeByUser();
   usePreferDrawerModeByUser();

+ 278 - 0
packages/app/src/components/Page/FixPageGrantAlert.tsx

@@ -0,0 +1,278 @@
+import React, { useEffect, useState, useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import { toastError, toastSuccess } from '~/client/util/apiNotification';
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { PageGrant, IPageGrantData } from '~/interfaces/page';
+import { IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
+import { useCurrentPageId, useHasParent } from '~/stores/context';
+import { useSWRxApplicableGrant, useSWRxIsGrantNormalized } from '~/stores/page';
+
+type ModalProps = {
+  isOpen: boolean
+  pageId: string
+  dataApplicableGrant: IRecordApplicableGrant
+  currentAndParentPageGrantData: IResIsGrantNormalizedGrantData
+  close(): void
+}
+
+const FixPageGrantModal = (props: ModalProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    isOpen, pageId, dataApplicableGrant, currentAndParentPageGrantData, close,
+  } = props;
+
+  const [selectedGrant, setSelectedGrant] = useState<PageGrant>(PageGrant.GRANT_RESTRICTED);
+  const [selectedGroup, setSelectedGroup] = useState<{_id: string, name: string} | undefined>(undefined); // TODO: Typescriptize model
+
+  // Alert message state
+  const [shouldShowModalAlert, setShowModalAlert] = useState<boolean>(false);
+
+  const applicableGroups = dataApplicableGrant[PageGrant.GRANT_USER_GROUP]?.applicableGroups;
+
+  // Reset state when opened
+  useEffect(() => {
+    if (isOpen) {
+      setSelectedGrant(PageGrant.GRANT_RESTRICTED);
+      setSelectedGroup(undefined);
+      setShowModalAlert(false);
+    }
+  }, [isOpen]);
+
+  const submit = async() => {
+    // Validate input values
+    if (selectedGrant === PageGrant.GRANT_USER_GROUP && selectedGroup == null) {
+      setShowModalAlert(true);
+      return;
+    }
+
+    close();
+
+    try {
+      await apiv3Put(`/page/${pageId}/grant`, {
+        grant: selectedGrant,
+        grantedGroup: selectedGroup?._id,
+      });
+
+      toastSuccess(t('Successfully updated'));
+    }
+    catch (err) {
+      toastError(t('Failed to update'));
+    }
+  };
+
+  const getGrantLabel = useCallback((isForbidden: boolean, grantData?: IPageGrantData): string => {
+
+    if (isForbidden) {
+      return t('fix_page_grant.modal.grant_label.isForbidden');
+    }
+
+    if (grantData == null) {
+      return t('fix_page_grant.modal.grant_label.isForbidden');
+    }
+
+    if (grantData.grant === 4) {
+      return t('fix_page_grant.modal.radio_btn.only_me');
+    }
+
+    if (grantData.grant === 5) {
+      if (grantData.grantedGroup == null) {
+        return t('fix_page_grant.modal.grant_label.isForbidden');
+      }
+      return `${t('fix_page_grant.modal.radio_btn.grant_group')}: (${grantData.grantedGroup.name})`;
+    }
+
+    throw Error('cannnot get grant label'); // this error can't be throwed
+  }, [t]);
+
+  const renderGrantDataLabel = useCallback(() => {
+    const { isForbidden, currentPageGrant, parentPageGrant } = currentAndParentPageGrantData;
+
+    const currentGrantLabel = getGrantLabel(false, currentPageGrant);
+    const parentGrantLabel = getGrantLabel(isForbidden, parentPageGrant);
+
+    return (
+      <>
+        <p className="mt-3">{ t('fix_page_grant.modal.grant_label.parentPageGrantLabel') + parentGrantLabel }</p>
+        <p>{ t('fix_page_grant.modal.grant_label.currentPageGrantLabel') + currentGrantLabel }</p>
+        {/* eslint-disable-next-line react/no-danger */}
+        <p dangerouslySetInnerHTML={{ __html: t('fix_page_grant.modal.grant_label.docLink') }} />
+      </>
+    );
+  }, [t, currentAndParentPageGrantData, getGrantLabel]);
+
+  const renderModalBodyAndFooter = () => {
+    const isGrantAvailable = Object.keys(dataApplicableGrant || {}).length > 0;
+
+    if (!isGrantAvailable) {
+      return (
+        <p className="m-5">
+          { t('fix_page_grant.modal.no_grant_available') }
+        </p>
+      );
+    }
+
+    return (
+      <>
+        <ModalBody>
+          <div className="form-group">
+            {/* eslint-disable-next-line react/no-danger */}
+            <p className="mb-2" dangerouslySetInnerHTML={{ __html: t('fix_page_grant.modal.need_to_fix_grant') }} />
+
+            {/* grant data label */}
+            {renderGrantDataLabel()}
+
+            <div className="ml-2">
+              <div className="custom-control custom-radio mb-3">
+                <input
+                  className="custom-control-input"
+                  name="grantRestricted"
+                  id="grantRestricted"
+                  type="radio"
+                  disabled={!(PageGrant.GRANT_RESTRICTED in dataApplicableGrant)}
+                  checked={selectedGrant === PageGrant.GRANT_RESTRICTED}
+                  onChange={() => setSelectedGrant(PageGrant.GRANT_RESTRICTED)}
+                />
+                <label className="custom-control-label" htmlFor="grantRestricted">
+                  { t('fix_page_grant.modal.radio_btn.restrected') }
+                </label>
+              </div>
+              <div className="custom-control custom-radio mb-3">
+                <input
+                  className="custom-control-input"
+                  name="grantUser"
+                  id="grantUser"
+                  type="radio"
+                  disabled={!(PageGrant.GRANT_OWNER in dataApplicableGrant)}
+                  checked={selectedGrant === PageGrant.GRANT_OWNER}
+                  onChange={() => setSelectedGrant(PageGrant.GRANT_OWNER)}
+                />
+                <label className="custom-control-label" htmlFor="grantUser">
+                  { t('fix_page_grant.modal.radio_btn.only_me') }
+                </label>
+              </div>
+              <div className="custom-control custom-radio d-flex mb-3">
+                <input
+                  className="custom-control-input"
+                  name="grantUserGroup"
+                  id="grantUserGroup"
+                  type="radio"
+                  disabled={!(PageGrant.GRANT_USER_GROUP in dataApplicableGrant)}
+                  checked={selectedGrant === PageGrant.GRANT_USER_GROUP}
+                  onChange={() => setSelectedGrant(PageGrant.GRANT_USER_GROUP)}
+                />
+                <label className="custom-control-label" htmlFor="grantUserGroup">
+                  { t('fix_page_grant.modal.radio_btn.grant_group') }
+                </label>
+                <div className="dropdown ml-2">
+                  <button
+                    type="button"
+                    className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
+                    data-toggle="dropdown"
+                    disabled={selectedGrant !== PageGrant.GRANT_USER_GROUP} // disable when its radio input is not selected
+                  >
+                    <span className="float-left ml-2">
+                      {
+                        selectedGroup == null
+                          ? t('fix_page_grant.modal.select_group_default_text')
+                          : selectedGroup.name
+                      }
+                    </span>
+                  </button>
+                  <div className="dropdown-menu">
+                    {
+                      applicableGroups != null && applicableGroups.map(g => (
+                        <button
+                          className="dropdown-item"
+                          type="button"
+                          onClick={() => setSelectedGroup(g)}
+                        >
+                          {g.name}
+                        </button>
+                      ))
+                    }
+                  </div>
+                </div>
+              </div>
+              {
+                shouldShowModalAlert && (
+                  <p className="alert alert-warning">
+                    {t('fix_page_grant.modal.alert_message')}
+                  </p>
+                )
+              }
+            </div>
+          </div>
+        </ModalBody>
+        <ModalFooter>
+          <button type="button" className="btn btn-primary" onClick={submit}>
+            { t('fix_page_grant.modal.btn_label') }
+          </button>
+        </ModalFooter>
+      </>
+    );
+  };
+
+  return (
+    <Modal size="lg" isOpen={isOpen} toggle={close} className="grw-create-page">
+      <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
+        { t('fix_page_grant.modal.title') }
+      </ModalHeader>
+      {renderModalBodyAndFooter()}
+    </Modal>
+  );
+};
+
+const FixPageGrantAlert = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  const [isOpen, setOpen] = useState<boolean>(false);
+
+  const { data: pageId } = useCurrentPageId();
+  const { data: hasParent } = useHasParent();
+  const { data: dataIsGrantNormalized } = useSWRxIsGrantNormalized(pageId);
+  const { data: dataApplicableGrant } = useSWRxApplicableGrant(pageId);
+
+  // Dependencies
+  if (!hasParent) {
+    return <></>;
+  }
+  if (dataIsGrantNormalized?.isGrantNormalized == null || dataIsGrantNormalized.isGrantNormalized) {
+    return <></>;
+  }
+
+  return (
+    <>
+      <div className="alert alert-warning py-3 pl-4 d-flex flex-column flex-lg-row">
+        <div className="flex-grow-1 d-flex align-items-center">
+          <i className="icon-fw icon-exclamation ml-1" aria-hidden="true" />
+          {t('fix_page_grant.alert.description')}
+        </div>
+        <div className="d-flex align-items-end align-items-lg-center">
+          <button type="button" className="btn btn-info btn-sm rounded-pill px-3" onClick={() => setOpen(true)}>
+            {t('fix_page_grant.alert.btn_label')}
+          </button>
+        </div>
+      </div>
+
+      {
+        pageId != null && dataApplicableGrant != null && (
+          <FixPageGrantModal
+            isOpen={isOpen}
+            pageId={pageId}
+            dataApplicableGrant={dataApplicableGrant}
+            currentAndParentPageGrantData={dataIsGrantNormalized.grantData}
+            close={() => setOpen(false)}
+          />
+        )
+      }
+    </>
+  );
+};
+
+export default FixPageGrantAlert;

+ 20 - 0
packages/app/src/interfaces/page-grant.ts

@@ -0,0 +1,20 @@
+import { PageGrant, IPageGrantData } from './page';
+
+export type IDataApplicableGroup = {
+  applicableGroups?: {_id: string, name: string}[] // TODO: Typescriptize model
+}
+
+export type IDataApplicableGrant = null | IDataApplicableGroup;
+export type IRecordApplicableGrant = Record<PageGrant, IDataApplicableGrant>
+export type IResApplicableGrant = {
+  data?: IRecordApplicableGrant
+}
+export type IResIsGrantNormalizedGrantData = {
+  isForbidden: boolean,
+  currentPageGrant: IPageGrantData,
+  parentPageGrant?: IPageGrantData
+}
+export type IResIsGrantNormalized = {
+  isGrantNormalized: boolean,
+  grantData: IResIsGrantNormalizedGrantData
+};

+ 18 - 1
packages/app/src/interfaces/page.ts

@@ -18,7 +18,7 @@ export interface IPage {
   parent: Ref<IPage> | null,
   parent: Ref<IPage> | null,
   descendantCount: number,
   descendantCount: number,
   isEmpty: boolean,
   isEmpty: boolean,
-  grant: number,
+  grant: PageGrant,
   grantedUsers: Ref<IUser>[],
   grantedUsers: Ref<IUser>[],
   grantedGroup: Ref<any>,
   grantedGroup: Ref<any>,
   lastUpdateUser: Ref<IUser>,
   lastUpdateUser: Ref<IUser>,
@@ -32,6 +32,15 @@ export interface IPage {
   deletedAt: Date,
   deletedAt: Date,
 }
 }
 
 
+export const PageGrant = {
+  GRANT_PUBLIC: 1,
+  GRANT_RESTRICTED: 2,
+  GRANT_SPECIFIED: 3,
+  GRANT_OWNER: 4,
+  GRANT_USER_GROUP: 5,
+};
+export type PageGrant = typeof PageGrant[keyof typeof PageGrant];
+
 export type IPageHasId = IPage & HasObjectId;
 export type IPageHasId = IPage & HasObjectId;
 
 
 export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean}>;
 export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean}>;
@@ -108,6 +117,14 @@ export type IPageWithMeta<M = IPageInfoAll> = IDataWithMeta<IPageHasId, M>;
 export type IPageToDeleteWithMeta = IDataWithMeta<HasObjectId & (IPage | { path: string, revision: string }), IPageInfoForEntity | unknown>;
 export type IPageToDeleteWithMeta = IDataWithMeta<HasObjectId & (IPage | { path: string, revision: string }), IPageInfoForEntity | unknown>;
 export type IPageToRenameWithMeta = IPageToDeleteWithMeta;
 export type IPageToRenameWithMeta = IPageToDeleteWithMeta;
 
 
+export type IPageGrantData = {
+  grant: number,
+  grantedGroup?: {
+    id: string,
+    name: string
+  }
+}
+
 export type IDeleteSinglePageApiv1Result = {
 export type IDeleteSinglePageApiv1Result = {
   ok: boolean
   ok: boolean
   path: string,
   path: string,

+ 12 - 5
packages/app/src/server/models/obsolete-page.js

@@ -742,14 +742,21 @@ export const getPageSchema = (crowi) => {
 
 
     // update existing page
     // update existing page
     let savedPage = await pageData.save();
     let savedPage = await pageData.save();
-    const newRevision = await Revision.prepareRevision(pageData, body, previousBody, user);
-    savedPage = await pushRevision(savedPage, newRevision, user);
-    await savedPage.populateDataToShowRevision();
 
 
-    if (isSyncRevisionToHackmd) {
-      savedPage = await this.syncRevisionToHackmd(savedPage);
+    // Update revision
+    const isBodyPresent = body != null && previousBody != null;
+    const shouldUpdateBody = isBodyPresent;
+    if (shouldUpdateBody) {
+      const newRevision = await Revision.prepareRevision(pageData, body, previousBody, user);
+      savedPage = await pushRevision(savedPage, newRevision, user);
+      await savedPage.populateDataToShowRevision();
+
+      if (isSyncRevisionToHackmd) {
+        savedPage = await this.syncRevisionToHackmd(savedPage);
+      }
     }
     }
 
 
+
     pageEvent.emit('update', savedPage, user);
     pageEvent.emit('update', savedPage, user);
 
 
     return savedPage;
     return savedPage;

+ 46 - 12
packages/app/src/server/models/page.ts

@@ -10,11 +10,10 @@ import mongoose, {
 import mongoosePaginate from 'mongoose-paginate-v2';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 import uniqueValidator from 'mongoose-unique-validator';
 
 
-import { IPage, IPageHasId } from '~/interfaces/page';
+import { IPage, IPageHasId, PageGrant } from '~/interfaces/page';
 import { IUserHasId } from '~/interfaces/user';
 import { IUserHasId } from '~/interfaces/user';
 import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 
 
-
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
 import Crowi from '../crowi';
 import Crowi from '../crowi';
 
 
@@ -945,6 +944,10 @@ schema.statics.findNotEmptyParentByPathRecursively = async function(path: string
   return notEmptyParent;
   return notEmptyParent;
 };
 };
 
 
+schema.statics.findParent = async function(pageId): Promise<PageDocument | null> {
+  return this.findOne({ _id: pageId });
+};
+
 schema.statics.PageQueryBuilder = PageQueryBuilder as any; // mongoose does not support constructor type as statics attrs type
 schema.statics.PageQueryBuilder = PageQueryBuilder as any; // mongoose does not support constructor type as statics attrs type
 
 
 export function generateGrantCondition(
 export function generateGrantCondition(
@@ -1014,7 +1017,31 @@ export default (crowi: Crowi): any => {
     pageEvent.emit('update', page, user);
     pageEvent.emit('update', page, user);
   };
   };
 
 
-  schema.statics.updatePage = async function(pageData, body, previousBody, user, options = {}) {
+  /**
+   * A wrapper method of schema.statics.updatePage for updating grant only.
+   * @param {PageDocument} page
+   * @param {UserDocument} user
+   * @param options
+   */
+  schema.statics.updateGrant = async function(page, user, grantData: {grant: PageGrant, grantedGroup: ObjectIdLike}) {
+    const { grant, grantedGroup } = grantData;
+
+    const options = {
+      grant,
+      grantUserGroupId: grantedGroup,
+      isSyncRevisionToHackmd: false,
+    };
+
+    return this.updatePage(page, null, null, user, options);
+  };
+
+  schema.statics.updatePage = async function(
+      pageData,
+      body: string | null,
+      previousBody: string | null,
+      user,
+      options: {grant?: PageGrant, grantUserGroupId?: ObjectIdLike, isSyncRevisionToHackmd?: boolean} = {},
+  ) {
     if (crowi.configManager == null || crowi.pageGrantService == null || crowi.pageService == null) {
     if (crowi.configManager == null || crowi.pageGrantService == null || crowi.pageService == null) {
       throw Error('Crowi is not set up');
       throw Error('Crowi is not set up');
     }
     }
@@ -1029,10 +1056,9 @@ export default (crowi: Crowi): any => {
       return this.updatePageV4(pageData, body, previousBody, user, options);
       return this.updatePageV4(pageData, body, previousBody, user, options);
     }
     }
 
 
-    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
-    const grant = options.grant || pageData.grant; // use the previous data if absence
+    const grant = options.grant ?? pageData.grant; // use the previous data if absence
     const grantUserGroupId: undefined | ObjectIdLike = options.grantUserGroupId ?? pageData.grantedGroup?._id.toString();
     const grantUserGroupId: undefined | ObjectIdLike = options.grantUserGroupId ?? pageData.grantedGroup?._id.toString();
-    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
+
     const grantedUserIds = pageData.grantedUserIds || [user._id];
     const grantedUserIds = pageData.grantedUserIds || [user._id];
     const shouldBeOnTree = grant !== GRANT_RESTRICTED;
     const shouldBeOnTree = grant !== GRANT_RESTRICTED;
     const isChildrenExist = await this.count({ path: new RegExp(`^${escapeStringRegexp(addTrailingSlash(pageData.path))}`), parent: { $ne: null } });
     const isChildrenExist = await this.count({ path: new RegExp(`^${escapeStringRegexp(addTrailingSlash(pageData.path))}`), parent: { $ne: null } });
@@ -1075,14 +1101,23 @@ export default (crowi: Crowi): any => {
 
 
     // update existing page
     // update existing page
     let savedPage = await newPageData.save();
     let savedPage = await newPageData.save();
-    const newRevision = await Revision.prepareRevision(newPageData, body, previousBody, user);
-    savedPage = await pushRevision(savedPage, newRevision, user);
-    await savedPage.populateDataToShowRevision();
 
 
-    if (isSyncRevisionToHackmd) {
-      savedPage = await this.syncRevisionToHackmd(savedPage);
+    // Update body
+    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
+    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
+    const isBodyPresent = body != null && previousBody != null;
+    const shouldUpdateBody = isBodyPresent;
+    if (shouldUpdateBody) {
+      const newRevision = await Revision.prepareRevision(newPageData, body, previousBody, user);
+      savedPage = await pushRevision(savedPage, newRevision, user);
+      await savedPage.populateDataToShowRevision();
+
+      if (isSyncRevisionToHackmd) {
+        savedPage = await this.syncRevisionToHackmd(savedPage);
+      }
     }
     }
 
 
+
     this.emitPageEventUpdate(savedPage, user);
     this.emitPageEventUpdate(savedPage, user);
 
 
     // Update ex children's parent
     // Update ex children's parent
@@ -1119,7 +1154,6 @@ export default (crowi: Crowi): any => {
     }
     }
 
 
     // 2. Delete unnecessary empty pages
     // 2. Delete unnecessary empty pages
-
     const shouldRemoveLeafEmpPages = wasOnTree && !isChildrenExist;
     const shouldRemoveLeafEmpPages = wasOnTree && !isChildrenExist;
     if (shouldRemoveLeafEmpPages) {
     if (shouldRemoveLeafEmpPages) {
       await this.removeLeafEmptyPagesRecursively(exParent);
       await this.removeLeafEmptyPagesRecursively(exParent);

+ 58 - 0
packages/app/src/server/models/user-group-relation.js

@@ -313,6 +313,64 @@ class UserGroupRelation {
     await this.bulkWrite(insertOperations);
     await this.bulkWrite(insertOperations);
   }
   }
 
 
+  /**
+   * Recursively finds descendant groups by populating relations.
+   * @static
+   * @param {UserGroupDocument[]} groups
+   * @param {UserDocument} user
+   * @returns UserGroupDocument[]
+   */
+  static async findGroupsWithDescendantsByGroupAndUser(group, user) {
+    const descendantGroups = [group];
+
+    const incrementGroupsRecursively = async(groups, user) => {
+      const groupIds = groups.map(g => g._id);
+
+      const populatedRelations = await this.aggregate([
+        {
+          $match: {
+            relatedUser: user._id,
+          },
+        },
+        {
+          $lookup: {
+            from: 'usergroups',
+            localField: 'relatedGroup',
+            foreignField: '_id',
+            as: 'relatedGroup',
+          },
+        },
+        {
+          $unwind: {
+            path: '$relatedGroup',
+          },
+        },
+        {
+          $match: {
+            'relatedGroup.parent': { $in: groupIds },
+          },
+        },
+      ]);
+
+      const nextGroups = populatedRelations.map(d => d.relatedGroup);
+
+      // End
+      const shouldEnd = nextGroups.length === 0;
+      if (shouldEnd) {
+        return;
+      }
+
+      // Increment
+      descendantGroups.push(...nextGroups);
+
+      return incrementGroupsRecursively(nextGroups, user);
+    };
+
+    await incrementGroupsRecursively([group], user);
+
+    return descendantGroups;
+  }
+
 }
 }
 
 
 module.exports = function(crowi) {
 module.exports = function(crowi) {

+ 168 - 2
packages/app/src/server/routes/apiv3/page.js

@@ -2,6 +2,7 @@ import { pagePathUtils } from '@growi/core';
 
 
 import { AllSubscriptionStatusType } from '~/interfaces/subscription';
 import { AllSubscriptionStatusType } from '~/interfaces/subscription';
 import Subscription from '~/server/models/subscription';
 import Subscription from '~/server/models/subscription';
+import UserGroup from '~/server/models/user-group';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
@@ -9,10 +10,10 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 const logger = loggerFactory('growi:routes:apiv3:page'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:page'); // eslint-disable-line no-unused-vars
 
 
 const express = require('express');
 const express = require('express');
-const { body, query } = require('express-validator');
+const { body, query, param } = require('express-validator');
 
 
 const router = express.Router();
 const router = express.Router();
-const { convertToNewAffiliationPath } = pagePathUtils;
+const { convertToNewAffiliationPath, isTopPage } = pagePathUtils;
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
 
 
@@ -179,6 +180,17 @@ module.exports = (crowi) => {
     info: [
     info: [
       query('pageId').isMongoId().withMessage('pageId is required'),
       query('pageId').isMongoId().withMessage('pageId is required'),
     ],
     ],
+    isGrantNormalized: [
+      query('pageId').isMongoId().withMessage('pageId is required'),
+    ],
+    applicableGrant: [
+      query('pageId').isMongoId().withMessage('pageId is required'),
+    ],
+    updateGrant: [
+      param('pageId').isMongoId().withMessage('pageId is required'),
+      body('grant').isInt().withMessage('grant is required'),
+      body('grantedGroup').optional().isMongoId().withMessage('grantedGroup must be a mongo id'),
+    ],
     export: [
     export: [
       query('format').isString().isIn(['md', 'pdf']),
       query('format').isString().isIn(['md', 'pdf']),
       query('revisionId').isString(),
       query('revisionId').isString(),
@@ -379,6 +391,160 @@ module.exports = (crowi) => {
 
 
   });
   });
 
 
+  /**
+   * @swagger
+   *
+   *    /page/is-grant-normalized:
+   *      get:
+   *        tags: [Page]
+   *        summary: /page/info
+   *        description: Retrieve current page's isGrantNormalized value
+   *        operationId: getIsGrantNormalized
+   *        parameters:
+   *          - name: pageId
+   *            in: query
+   *            description: page id
+   *            schema:
+   *              $ref: '#/components/schemas/Page/properties/_id'
+   *        responses:
+   *          200:
+   *            description: Successfully retrieved current isGrantNormalized.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  type: object
+   *                  properties:
+   *                    isGrantNormalized:
+   *                      type: boolean
+   *          400:
+   *            description: Bad request. Page is unreachable or empty.
+   *          500:
+   *            description: Internal server error.
+   */
+  router.get('/is-grant-normalized', loginRequiredStrictly, validator.isGrantNormalized, apiV3FormValidator, async(req, res) => {
+    const { pageId } = req.query;
+
+    const Page = crowi.model('Page');
+    const page = await Page.findByIdAndViewer(pageId, req.user, null, false);
+
+    if (page == null) {
+      return res.apiv3Err(new ErrorV3('Page is unreachable or empty.', 'page_unreachable_or_empty'), 400);
+    }
+
+    const {
+      path, grant, grantedUsers, grantedGroup,
+    } = page;
+
+    let isGrantNormalized;
+    try {
+      isGrantNormalized = await crowi.pageGrantService.isGrantNormalized(req.user, path, grant, grantedUsers, grantedGroup, false, false);
+    }
+    catch (err) {
+      logger.error('Error occurred while processing isGrantNormalized.', err);
+      return res.apiv3Err(err, 500);
+    }
+
+    const currentPageUserGroup = await UserGroup.findOne({ _id: grantedGroup });
+    const currentPageGrant = {
+      grant,
+      grantedGroup: currentPageUserGroup != null
+        ? {
+          id: currentPageUserGroup._id,
+          name: currentPageUserGroup.name,
+        }
+        : null,
+    };
+
+    // page doesn't have parent page
+    if (page.parent == null) {
+      const grantData = {
+        isForbidden: false,
+        currentPageGrant,
+        parentPageGrant: null,
+      };
+      return res.apiv3({ isGrantNormalized, grantData });
+    }
+
+    const parentPage = await Page.findByIdAndViewer(page.parent, req.user, null, false);
+
+    // user isn't allowed to see parent's grant
+    if (parentPage == null) {
+      const grantData = {
+        isForbidden: true,
+        currentPageGrant,
+        parentPageGrant: null,
+      };
+      return res.apiv3({ isGrantNormalized, grantData });
+    }
+
+    const parentPageUserGroup = await UserGroup.findOne({ _id: parentPage.grantedGroup });
+    const parentPageGrant = {
+      grant: parentPage.grant,
+      grantedGroup: parentPageUserGroup != null
+        ? {
+          id: parentPageUserGroup._id,
+          name: parentPageUserGroup.name,
+        }
+        : null,
+    };
+
+    const grantData = {
+      isForbidden: false,
+      currentPageGrant,
+      parentPageGrant,
+    };
+
+    return res.apiv3({ isGrantNormalized, grantData });
+  });
+
+  router.get('/applicable-grant', loginRequiredStrictly, validator.applicableGrant, apiV3FormValidator, async(req, res) => {
+    const { pageId } = req.query;
+
+    const Page = crowi.model('Page');
+    const page = await Page.findByIdAndViewer(pageId, req.user, null);
+
+    if (page == null) {
+      return res.apiv3Err(new ErrorV3('Page is unreachable or empty.', 'page_unreachable_or_empty'), 400);
+    }
+
+    let data;
+    try {
+      data = await crowi.pageGrantService.calcApplicableGrantData(page, req.user);
+    }
+    catch (err) {
+      logger.error('Error occurred while processing calcApplicableGrantData.', err);
+      return res.apiv3Err(err, 500);
+    }
+
+    return res.apiv3(data);
+  });
+
+  router.put('/:pageId/grant', loginRequiredStrictly, validator.updateGrant, apiV3FormValidator, async(req, res) => {
+    const { pageId } = req.params;
+    const { grant, grantedGroup } = req.body;
+
+    const Page = crowi.model('Page');
+
+    const page = await Page.findByIdAndViewer(pageId, req.user, null, false);
+
+    if (page == null) {
+      return res.apiv3Err(new ErrorV3('Page is unreachable or empty.', 'page_unreachable_or_empty'), 400);
+    }
+
+    let data;
+    try {
+      const shouldUseV4Process = false;
+      const grantData = { grant, grantedGroup };
+      data = await Page.updateGrant(page, req.user, grantData, shouldUseV4Process);
+    }
+    catch (err) {
+      logger.error('Error occurred while processing calcApplicableGrantData.', err);
+      return res.apiv3Err(err, 500);
+    }
+
+    return res.apiv3(data);
+  });
+
   /**
   /**
   * @swagger
   * @swagger
   *
   *

+ 71 - 2
packages/app/src/server/service/page-grant.ts

@@ -1,9 +1,10 @@
-import mongoose from 'mongoose';
 import { pagePathUtils, pathUtils, pageUtils } from '@growi/core';
 import { pagePathUtils, pathUtils, pageUtils } from '@growi/core';
 import escapeStringRegexp from 'escape-string-regexp';
 import escapeStringRegexp from 'escape-string-regexp';
+import mongoose from 'mongoose';
 
 
-import UserGroup from '~/server/models/user-group';
+import { IRecordApplicableGrant } from '~/interfaces/page-grant';
 import { PageDocument, PageModel } from '~/server/models/page';
 import { PageDocument, PageModel } from '~/server/models/page';
+import UserGroup from '~/server/models/user-group';
 import { isIncludesObjectId, excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 import { isIncludesObjectId, excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 
 
 const { addTrailingSlash } = pathUtils;
 const { addTrailingSlash } = pathUtils;
@@ -403,6 +404,74 @@ class PageGrantService {
     return [normalizable, nonNormalizable];
     return [normalizable, nonNormalizable];
   }
   }
 
 
+  async calcApplicableGrantData(page, user): Promise<IRecordApplicableGrant> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
+
+    // Increment an object (type IRecordApplicableGrant)
+    // grant is never public, anyone with the link, nor specified
+    const data: IRecordApplicableGrant = {
+      [Page.GRANT_RESTRICTED]: null, // any page can be restricted
+    };
+
+    // -- Public only if top page
+    const isOnlyPublicApplicable = isTopPage(page.path);
+    if (isOnlyPublicApplicable) {
+      data[Page.GRANT_PUBLIC] = null;
+      return data;
+    }
+
+    // -- Any grant is allowed if parent is null
+    const isAnyGrantApplicable = page.parent == null;
+    if (isAnyGrantApplicable) {
+      data[Page.GRANT_PUBLIC] = null;
+      data[Page.GRANT_OWNER] = null;
+      data[Page.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+      return data;
+    }
+
+    const parent = await Page.findById(page.parent);
+    if (parent == null) {
+      throw Error('The page\'s parent does not exist.');
+    }
+
+    const {
+      grant, grantedUsers, grantedGroup,
+    } = parent;
+
+    if (grant === Page.GRANT_PUBLIC) {
+      data[Page.GRANT_PUBLIC] = null;
+      data[Page.GRANT_OWNER] = null;
+      data[Page.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    }
+    else if (grant === Page.GRANT_OWNER) {
+      const grantedUser = grantedUsers[0];
+
+      const isUserApplicable = grantedUser.toString() === user._id.toString();
+
+      if (isUserApplicable) {
+        data[Page.GRANT_OWNER] = null;
+      }
+    }
+    else if (grant === Page.GRANT_USER_GROUP) {
+      const group = await UserGroup.findById(grantedGroup);
+      if (group == null) {
+        throw Error('Group not found to calculate grant data.');
+      }
+
+      const applicableGroups = await UserGroupRelation.findGroupsWithDescendantsByGroupAndUser(group, user);
+
+      const isUserExistInGroup = await UserGroupRelation.countByGroupIdAndUser(group, user) > 0;
+
+      if (isUserExistInGroup) {
+        data[Page.GRANT_OWNER] = null;
+      }
+      data[Page.GRANT_USER_GROUP] = { applicableGroups };
+    }
+
+    return data;
+  }
+
 }
 }
 
 
 export default PageGrantService;
 export default PageGrantService;

+ 2 - 0
packages/app/src/server/views/widget/page_alerts.html

@@ -69,5 +69,7 @@
     {% if isTrashPage(page.path) %}
     {% if isTrashPage(page.path) %}
       <div id="trash-page-alert"></div>
       <div id="trash-page-alert"></div>
     {% endif %}
     {% endif %}
+
+    <div id="fix-page-grant-alert"></div>
   </div>
   </div>
 </div>
 </div>

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

@@ -25,6 +25,7 @@
   data-page-user="{% if pageUser %}{{ pageUser|json }}{% else %}null{% endif %}"
   data-page-user="{% if pageUser %}{{ pageUser|json }}{% else %}null{% endif %}"
   data-share-links-number="{% if page %}{{ sharelinksNumber }}{% endif %}"
   data-share-links-number="{% if page %}{{ sharelinksNumber }}{% endif %}"
   data-share-link-id="{% if sharelink %}{{ sharelink._id|json }}{% endif %}"
   data-share-link-id="{% if sharelink %}{{ sharelink._id|json }}{% endif %}"
+  data-has-parent="{{ page.parent != null }}"
   >
   >
 {% else %}
 {% else %}
 <div id="content-main" class="content-main d-flex"
 <div id="content-main" class="content-main d-flex"

+ 4 - 0
packages/app/src/stores/context.tsx

@@ -156,6 +156,10 @@ export const useIsEnabledAttachTitleHeader = (initialData?: boolean) : SWRRespon
   return useStaticSWR<boolean, Error>('isEnabledAttachTitleHeader', initialData);
   return useStaticSWR<boolean, Error>('isEnabledAttachTitleHeader', initialData);
 };
 };
 
 
+export const useHasParent = (initialData?: boolean) : SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('hasParent', initialData);
+};
+
 export const useIsIndentSizeForced = (initialData?: boolean) : SWRResponse<boolean, Error> => {
 export const useIsIndentSizeForced = (initialData?: boolean) : SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>('isIndentSizeForced', initialData);
   return useStaticSWR<boolean, Error>('isIndentSizeForced', initialData);
 };
 };

+ 25 - 1
packages/app/src/stores/page.tsx

@@ -1,12 +1,13 @@
 import useSWR, { SWRResponse } from 'swr';
 import useSWR, { SWRResponse } from 'swr';
-import useSWRInfinite, { SWRInfiniteResponse } from 'swr/infinite';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
+import useSWRInfinite, { SWRInfiniteResponse } from 'swr/infinite';
 
 
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { HasObjectId } from '~/interfaces/has-object-id';
 import { HasObjectId } from '~/interfaces/has-object-id';
 import {
 import {
   IPageInfo, IPageHasId, IPageInfoForOperation, IPageInfoForListing, IDataWithMeta,
   IPageInfo, IPageHasId, IPageInfoForOperation, IPageInfoForListing, IDataWithMeta,
 } from '~/interfaces/page';
 } from '~/interfaces/page';
+import { IRecordApplicableGrant, IResIsGrantNormalized } from '~/interfaces/page-grant';
 import { IPagingResult } from '~/interfaces/paging-result';
 import { IPagingResult } from '~/interfaces/paging-result';
 
 
 import { apiGet } from '../client/util/apiv1-client';
 import { apiGet } from '../client/util/apiv1-client';
@@ -146,3 +147,26 @@ export const useSWRxPageInfoForList = (
     },
     },
   };
   };
 };
 };
+
+/*
+ * Grant normalization fetching hooks
+ */
+export const useSWRxIsGrantNormalized = (
+    pageId: string | null | undefined,
+): SWRResponse<IResIsGrantNormalized, Error> => {
+
+  return useSWRImmutable(
+    pageId != null ? ['/page/is-grant-normalized', pageId] : null,
+    (endpoint, pageId) => apiv3Get(endpoint, { pageId }).then(response => response.data),
+  );
+};
+
+export const useSWRxApplicableGrant = (
+    pageId: string | null | undefined,
+): SWRResponse<IRecordApplicableGrant, Error> => {
+
+  return useSWRImmutable(
+    pageId != null ? ['/page/applicable-grant', pageId] : null,
+    (endpoint, pageId) => apiv3Get(endpoint, { pageId }).then(response => response.data),
+  );
+};