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

Merge pull request #5830 from weseek/feat/fix-grant-alert-applicable-data-apis

feat: Fix grant alert applicable data apis
Haku Mizuki 3 лет назад
Родитель
Сommit
89a7d12eab

+ 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,
 } from '../../stores/context';
 } from '../../stores/context';
 
 
@@ -70,6 +70,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);
@@ -141,6 +142,7 @@ const ContextExtractorOnce: FC = () => {
   useNotFoundTargetPathOrId(notFoundTargetPathOrId);
   useNotFoundTargetPathOrId(notFoundTargetPathOrId);
   useIsNotFoundPermalink(isNotFoundPermalink);
   useIsNotFoundPermalink(isNotFoundPermalink);
   useIsSearchPage(isSearchPage);
   useIsSearchPage(isSearchPage);
+  useHasParent(hasParent);
 
 
   // Navigation
   // Navigation
   usePreferDrawerModeByUser();
   usePreferDrawerModeByUser();

+ 99 - 78
packages/app/src/components/Page/FixPageGrantAlert.tsx

@@ -8,13 +8,14 @@ import {
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { PageGrant } from '~/interfaces/page';
 import { PageGrant } from '~/interfaces/page';
-import { useCurrentPageId } from '~/stores/context';
-import { IResApplicableGrant, useSWRxApplicableGrant, useSWRxIsGrantNormalized } from '~/stores/page';
+import { IRecordApplicableGrant } from '~/interfaces/page-grant';
+import { useCurrentPageId, useHasParent } from '~/stores/context';
+import { useSWRxApplicableGrant, useSWRxIsGrantNormalized } from '~/stores/page';
 
 
 type ModalProps = {
 type ModalProps = {
   isOpen: boolean
   isOpen: boolean
   pageId: string
   pageId: string
-  dataApplicableGrant: IResApplicableGrant
+  dataApplicableGrant: IRecordApplicableGrant
   close(): void
   close(): void
 }
 }
 
 
@@ -31,7 +32,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   // Alert message state
   // Alert message state
   const [shouldShowModalAlert, setShowModalAlert] = useState<boolean>(false);
   const [shouldShowModalAlert, setShowModalAlert] = useState<boolean>(false);
 
 
-  const applicableGroups = dataApplicableGrant.data.find(d => d.grant === PageGrant.GRANT_USER_GROUP)?.applicableGroups;
+  const applicableGroups = dataApplicableGrant[PageGrant.GRANT_USER_GROUP]?.applicableGroups;
 
 
   // Reset state when opened
   // Reset state when opened
   useEffect(() => {
   useEffect(() => {
@@ -63,8 +64,8 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     toastSuccess();
     toastSuccess();
   };
   };
 
 
-  const renderModalBody = () => {
-    const isGrantAvailable = dataApplicableGrant.data.length > 0;
+  const renderModalBodyAndFooter = () => {
+    const isGrantAvailable = Object.keys(dataApplicableGrant || {}).length > 0;
 
 
     if (!isGrantAvailable) {
     if (!isGrantAvailable) {
       return (
       return (
@@ -75,79 +76,99 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     }
     }
 
 
     return (
     return (
-      <ModalBody>
-        <div className="form-group grw-scrollable-modal-body">
-          <p className="mb-2">
-            You need to fix the grant of this page. Select new grant from below.
-          </p>
-          <div className="ml-2">
-            <div className="custom-control custom-radio mb-3">
-              <input
-                className="custom-control-input"
-                name="grantUser"
-                id="grantUser"
-                type="radio"
-                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"
-                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">
-                <div className="d-flex">
-                  <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">
+      <>
+        <ModalBody>
+          <div className="form-group grw-scrollable-modal-body">
+            <p className="mb-2">
+              You need to fix the grant of this page. Select new grant from below.
+            </p>
+            <div className="ml-2">
+              <div className="custom-control custom-radio mb-3">
+                <input
+                  className="custom-control-input"
+                  name="grantUser"
+                  id="grantUser"
+                  type="radio"
+                  checked={selectedGrant === PageGrant.GRANT_RESTRICTED}
+                  onChange={() => setSelectedGrant(PageGrant.GRANT_RESTRICTED)}
+                />
+                <label className="custom-control-label" htmlFor="grantUser">
+                  { 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"
+                  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"
+                  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">
+                  <div className="d-flex">
+                    <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">
+                        {
+                          selectedGroupName == null
+                            ? t('fix_page_grant.modal.select_group_default_text')
+                            : selectedGroupName
+                        }
+                      </span>
+                    </button>
+                    <div className="dropdown-menu">
                       {
                       {
-                        selectedGroupName == null
-                          ? t('fix_page_grant.modal.select_group_default_text')
-                          : selectedGroupName
+                        applicableGroups != null && applicableGroups.map(g => (
+                          <button
+                            className="dropdown-item"
+                            type="button"
+                            onClick={() => setSelectedGroupName(g.name)}
+                          >
+                            {g.name}
+                          </button>
+                        ))
                       }
                       }
-                    </span>
-                  </button>
-                  <div className="dropdown-menu">
-                    {
-                      applicableGroups != null && applicableGroups.map(g => (
-                        <button
-                          className="dropdown-item"
-                          type="button"
-                          onClick={() => setSelectedGroupName(g.name)}
-                        >
-                          {g.name}
-                        </button>
-                      ))
-                    }
+                    </div>
                   </div>
                   </div>
                 </div>
                 </div>
               </div>
               </div>
+              {
+                shouldShowModalAlert && (
+                  <p className="alert alert-warning">
+                    {t('fix_page_grant.modal.alert_message')}
+                  </p>
+                )
+              }
             </div>
             </div>
-            {
-              shouldShowModalAlert && (
-                <p className="alert alert-warning">
-                  {t('fix_page_grant.modal.alert_message')}
-                </p>
-              )
-            }
           </div>
           </div>
-        </div>
-      </ModalBody>
+        </ModalBody>
+        <ModalFooter>
+          <button type="button" className="btn btn-primary" onClick={submit}>
+            { t('fix_page_grant.modal.button_label') }
+          </button>
+        </ModalFooter>
+      </>
     );
     );
   };
   };
 
 
@@ -156,12 +177,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
       <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
       <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
         { t('fix_page_grant.modal.title') }
         { t('fix_page_grant.modal.title') }
       </ModalHeader>
       </ModalHeader>
-      {renderModalBody()}
-      <ModalFooter>
-        <button type="button" className="btn btn-primary" onClick={submit}>
-          { t('fix_page_grant.modal.button_label') }
-        </button>
-      </ModalFooter>
+      {renderModalBodyAndFooter()}
     </Modal>
     </Modal>
   );
   );
 };
 };
@@ -172,9 +188,14 @@ const FixPageGrantAlert = (): JSX.Element => {
   const [isOpen, setOpen] = useState<boolean>(false);
   const [isOpen, setOpen] = useState<boolean>(false);
 
 
   const { data: pageId } = useCurrentPageId();
   const { data: pageId } = useCurrentPageId();
+  const { data: hasParent } = useHasParent();
   const { data: dataIsGrantNormalized } = useSWRxIsGrantNormalized(pageId);
   const { data: dataIsGrantNormalized } = useSWRxIsGrantNormalized(pageId);
   const { data: dataApplicableGrant } = useSWRxApplicableGrant(pageId);
   const { data: dataApplicableGrant } = useSWRxApplicableGrant(pageId);
 
 
+  // Dependencies
+  if (!hasParent) {
+    return <></>;
+  }
   if (dataIsGrantNormalized?.isGrantNormalized == null || dataIsGrantNormalized.isGrantNormalized) {
   if (dataIsGrantNormalized?.isGrantNormalized == null || dataIsGrantNormalized.isGrantNormalized) {
     return <></>;
     return <></>;
   }
   }

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

@@ -0,0 +1,11 @@
+import { PageGrant } 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
+}

+ 4 - 0
packages/app/src/server/models/page.ts

@@ -999,6 +999,10 @@ schema.statics.removeEmptyPages = async function(pageIdsToNotRemove: ObjectIdLik
   });
   });
 };
 };
 
 
+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(

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

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

@@ -1,8 +1,9 @@
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
-import loggerFactory from '~/utils/logger';
+
 
 
 import { AllSubscriptionStatusType } from '~/interfaces/subscription';
 import { AllSubscriptionStatusType } from '~/interfaces/subscription';
 import Subscription from '~/server/models/subscription';
 import Subscription from '~/server/models/subscription';
+import loggerFactory from '~/utils/logger';
 
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
@@ -12,7 +13,7 @@ const express = require('express');
 const { body, query } = require('express-validator');
 const { body, query } = 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');
 
 
 
 
@@ -182,6 +183,9 @@ module.exports = (crowi) => {
     isGrantNormalized: [
     isGrantNormalized: [
       query('pageId').isMongoId().withMessage('pageId is required'),
       query('pageId').isMongoId().withMessage('pageId is required'),
     ],
     ],
+    applicableGrant: [
+      query('pageId').isMongoId().withMessage('pageId is required'),
+    ],
     export: [
     export: [
       query('format').isString().isIn(['md', 'pdf']),
       query('format').isString().isIn(['md', 'pdf']),
       query('revisionId').isString(),
       query('revisionId').isString(),
@@ -438,6 +442,33 @@ module.exports = (crowi) => {
     return res.apiv3({ isGrantNormalized });
     return res.apiv3({ isGrantNormalized });
   });
   });
 
 
+  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);
+  });
+
+  // TODO: implement
+  // router.put('/:pageId/grant', loginRequiredStrictly, validator.isGrantNormalized, apiV3FormValidator, async(req, res) => {
+  //   return;
+  // });
+
   /**
   /**
   * @swagger
   * @swagger
   *
   *

+ 68 - 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,71 @@ 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;
+    }
+
+    // -- Public is not applicable below
+    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);
+
+      data[Page.GRANT_OWNER] = null;
+      data[Page.GRANT_USER_GROUP] = { applicableGroups };
+    }
+
+    return data;
+  }
+
 }
 }
 
 
 export default PageGrantService;
 export default PageGrantService;

+ 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);
+};
+
 
 
 /** **********************************************************
 /** **********************************************************
  *                     Computed contexts
  *                     Computed contexts

+ 4 - 10
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, PageGrant,
+  IPageInfo, IPageHasId, IPageInfoForOperation, IPageInfoForListing, IDataWithMeta,
 } from '~/interfaces/page';
 } from '~/interfaces/page';
+import { IRecordApplicableGrant } 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';
@@ -161,16 +162,9 @@ export const useSWRxIsGrantNormalized = (
   );
   );
 };
 };
 
 
-export type IApplicableGrant = {
-  grant: PageGrant
-  applicableGroups?: {_id: string, name: string}[] // TODO: Typescriptize model
-}
-export type IResApplicableGrant = {
-  data: IApplicableGrant[]
-}
 export const useSWRxApplicableGrant = (
 export const useSWRxApplicableGrant = (
     pageId: string | null | undefined,
     pageId: string | null | undefined,
-): SWRResponse<IResApplicableGrant, Error> => {
+): SWRResponse<IRecordApplicableGrant, Error> => {
 
 
   return useSWRImmutable(
   return useSWRImmutable(
     pageId != null ? ['/page/applicable-grant', pageId] : null,
     pageId != null ? ['/page/applicable-grant', pageId] : null,