Explorar el Código

Merge remote-tracking branch 'origin/dev/7.0.x' into imprv/attachment

Yuki Takei hace 2 años
padre
commit
4b9c8ea4f9

+ 5 - 12
apps/app/src/components/CustomNavigation/CustomNav.module.scss

@@ -1,15 +1,3 @@
-.grw-custom-nav-tab,
-.grw-custom-nav-dropdown {
-  :global {
-    svg {
-      width: 17px;
-      height: 17px;
-      margin-right: 5px;
-      vertical-align: text-bottom;
-    }
-  }
-}
-
 .grw-custom-nav-tab :global {
 .grw-custom-nav-tab :global {
   .nav-title {
   .nav-title {
     flex-wrap: nowrap;
     flex-wrap: nowrap;
@@ -24,4 +12,9 @@
     border-bottom: 3px solid;
     border-bottom: 3px solid;
     transition: 0.3s ease-in-out;
     transition: 0.3s ease-in-out;
   }
   }
+
+  .material-symbols-outlined {
+    margin-right: 6px;
+    font-size: 18px;
+  }
 }
 }

+ 3 - 3
apps/app/src/components/CustomNavigation/CustomNav.tsx

@@ -2,12 +2,12 @@ import React, {
   useEffect, useState, useRef, useMemo, useCallback,
   useEffect, useState, useRef, useMemo, useCallback,
 } from 'react';
 } from 'react';
 
 
-import { Breakpoint } from '@growi/ui/dist/interfaces';
+import type { Breakpoint } from '@growi/ui/dist/interfaces';
 import {
 import {
   Nav, NavItem, NavLink,
   Nav, NavItem, NavLink,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import { ICustomNavTabMappings } from '~/interfaces/ui';
+import type { ICustomNavTabMappings } from '~/interfaces/ui';
 
 
 import styles from './CustomNav.module.scss';
 import styles from './CustomNav.module.scss';
 
 
@@ -49,7 +49,7 @@ export const CustomNavDropdown = (props: CustomNavDropdownProps): JSX.Element =>
   }, [onNavSelected]);
   }, [onNavSelected]);
 
 
   return (
   return (
-    <div className="grw-custom-nav-dropdown btn-group">
+    <div className="btn-group">
       <button
       <button
         className="btn btn-outline-primary btn-lg dropdown-toggle text-end"
         className="btn btn-outline-primary btn-lg dropdown-toggle text-end"
         type="button"
         type="button"

+ 13 - 13
apps/app/src/components/Me/BasicInfoSettings.tsx

@@ -49,7 +49,7 @@ export const BasicInfoSettings = (): JSX.Element => {
   return (
   return (
     <>
     <>
 
 
-      <div className="row">
+      <div className="row mt-3 mt-md-4">
         <label htmlFor="userForm[name]" className="text-start text-md-end col-md-3 col-form-label">{t('Name')}</label>
         <label htmlFor="userForm[name]" className="text-start text-md-end col-md-3 col-form-label">{t('Name')}</label>
         <div className="col-md-6">
         <div className="col-md-6">
           <input
           <input
@@ -62,7 +62,7 @@ export const BasicInfoSettings = (): JSX.Element => {
         </div>
         </div>
       </div>
       </div>
 
 
-      <div className="row">
+      <div className="row mt-3">
         <label htmlFor="userForm[email]" className="text-start text-md-end col-md-3 col-form-label">{t('Email')}</label>
         <label htmlFor="userForm[email]" className="text-start text-md-end col-md-3 col-form-label">{t('Email')}</label>
         <div className="col-md-6">
         <div className="col-md-6">
           <input
           <input
@@ -83,10 +83,10 @@ export const BasicInfoSettings = (): JSX.Element => {
         </div>
         </div>
       </div>
       </div>
 
 
-      <div className="row">
+      <div className="row mt-3">
         <label className="text-start text-md-end col-md-3 col-form-label">{t('Disclose E-mail')}</label>
         <label className="text-start text-md-end col-md-3 col-form-label">{t('Disclose E-mail')}</label>
-        <div className="col-md-6">
-          <div className="form-check form-check-inline">
+        <div className="col-md-6 my-auto">
+          <div className="form-check form-check-inline me-4">
             <input
             <input
               type="radio"
               type="radio"
               id="radioEmailShow"
               id="radioEmailShow"
@@ -95,7 +95,7 @@ export const BasicInfoSettings = (): JSX.Element => {
               checked={personalSettingsInfo?.isEmailPublished === true}
               checked={personalSettingsInfo?.isEmailPublished === true}
               onChange={() => changePersonalSettingsHandler({ isEmailPublished: true })}
               onChange={() => changePersonalSettingsHandler({ isEmailPublished: true })}
             />
             />
-            <label className="form-label form-check-label" htmlFor="radioEmailShow">{t('Show')}</label>
+            <label className="form-label form-check-label mb-0" htmlFor="radioEmailShow">{t('Show')}</label>
           </div>
           </div>
           <div className="form-check form-check-inline">
           <div className="form-check form-check-inline">
             <input
             <input
@@ -106,21 +106,21 @@ export const BasicInfoSettings = (): JSX.Element => {
               checked={personalSettingsInfo?.isEmailPublished === false}
               checked={personalSettingsInfo?.isEmailPublished === false}
               onChange={() => changePersonalSettingsHandler({ isEmailPublished: false })}
               onChange={() => changePersonalSettingsHandler({ isEmailPublished: false })}
             />
             />
-            <label className="form-label form-check-label" htmlFor="radioEmailHide">{t('Hide')}</label>
+            <label className="form-label form-check-label mb-0" htmlFor="radioEmailHide">{t('Hide')}</label>
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
 
 
-      <div className="row">
+      <div className="row mt-3">
         <label className="text-start text-md-end col-md-3 col-form-label">{t('Language')}</label>
         <label className="text-start text-md-end col-md-3 col-form-label">{t('Language')}</label>
-        <div className="col-md-6">
+        <div className="col-md-6 my-auto">
           {
           {
             i18nConfig.locales.map((locale) => {
             i18nConfig.locales.map((locale) => {
               if (i18n == null) { return }
               if (i18n == null) { return }
               const fixedT = i18n.getFixedT(locale);
               const fixedT = i18n.getFixedT(locale);
 
 
               return (
               return (
-                <div key={locale} className="form-check form-check-inline">
+                <div key={locale} className="form-check form-check-inline me-4">
                   <input
                   <input
                     type="radio"
                     type="radio"
                     id={`radioLang${locale}`}
                     id={`radioLang${locale}`}
@@ -129,14 +129,14 @@ export const BasicInfoSettings = (): JSX.Element => {
                     checked={personalSettingsInfo?.lang === locale}
                     checked={personalSettingsInfo?.lang === locale}
                     onChange={() => changePersonalSettingsHandler({ lang: locale })}
                     onChange={() => changePersonalSettingsHandler({ lang: locale })}
                   />
                   />
-                  <label className="form-label form-check-label" htmlFor={`radioLang${locale}`}>{fixedT('meta.display_name') as string}</label>
+                  <label className="form-label form-check-label mb-0" htmlFor={`radioLang${locale}`}>{fixedT('meta.display_name') as string}</label>
                 </div>
                 </div>
               );
               );
             })
             })
           }
           }
         </div>
         </div>
       </div>
       </div>
-      <div className="row">
+      <div className="row mt-3">
         <label htmlFor="userForm[slackMemberId]" className="text-start text-md-end col-md-3 col-form-label">{t('Slack Member ID')}</label>
         <label htmlFor="userForm[slackMemberId]" className="text-start text-md-end col-md-3 col-form-label">{t('Slack Member ID')}</label>
         <div className="col-md-6">
         <div className="col-md-6">
           <input
           <input
@@ -150,7 +150,7 @@ export const BasicInfoSettings = (): JSX.Element => {
         </div>
         </div>
       </div>
       </div>
 
 
-      <div className="row my-3">
+      <div className="row mt-4">
         <div className="offset-4 col-5">
         <div className="offset-4 col-5">
           <button
           <button
             data-testid="grw-besic-info-settings-update-button"
             data-testid="grw-besic-info-settings-update-button"

+ 6 - 6
apps/app/src/components/Me/PersonalSettings.jsx

@@ -20,22 +20,22 @@ const PersonalSettings = () => {
   const navTabMapping = useMemo(() => {
   const navTabMapping = useMemo(() => {
     return {
     return {
       user_infomation: {
       user_infomation: {
-        Icon: () => <i className="icon-fw icon-user"></i>,
+        Icon: () => <span className="material-symbols-outlined">person</span>,
         Content: UserSettings,
         Content: UserSettings,
         i18n: t('User Information'),
         i18n: t('User Information'),
       },
       },
       external_accounts: {
       external_accounts: {
-        Icon: () => <i className="icon-fw icon-share-alt"></i>,
+        Icon: () => <span className="material-symbols-outlined">ungroup</span>,
         Content: ExternalAccountLinkedMe,
         Content: ExternalAccountLinkedMe,
         i18n: t('admin:user_management.external_accounts'),
         i18n: t('admin:user_management.external_accounts'),
       },
       },
       password_settings: {
       password_settings: {
-        Icon: () => <i className="icon-fw icon-lock"></i>,
+        Icon: () => <span className="material-symbols-outlined">password</span>,
         Content: PasswordSettings,
         Content: PasswordSettings,
         i18n: t('Password Settings'),
         i18n: t('Password Settings'),
       },
       },
       api_settings: {
       api_settings: {
-        Icon: () => <i className="icon-fw icon-paper-plane"></i>,
+        Icon: () => <span className="material-symbols-outlined">api</span>,
         Content: ApiSettings,
         Content: ApiSettings,
         i18n: t('API Settings'),
         i18n: t('API Settings'),
       },
       },
@@ -45,12 +45,12 @@ const PersonalSettings = () => {
       //   i18n: t('editor_settings.editor_settings'),
       //   i18n: t('editor_settings.editor_settings'),
       // },
       // },
       in_app_notification_settings: {
       in_app_notification_settings: {
-        Icon: () => <i className="icon-fw icon-bell"></i>,
+        Icon: () => <span className="material-symbols-outlined">notifications</span>,
         Content: InAppNotificationSettings,
         Content: InAppNotificationSettings,
         i18n: t('in_app_notification_settings.in_app_notification_settings'),
         i18n: t('in_app_notification_settings.in_app_notification_settings'),
       },
       },
       other_settings: {
       other_settings: {
-        Icon: () => <i className="icon-fw icon-settings"></i>,
+        Icon: () => <span className="material-symbols-outlined">settings</span>,
         Content: OtherSettings,
         Content: OtherSettings,
         i18n: t('Other Settings'),
         i18n: t('Other Settings'),
       },
       },

+ 19 - 19
apps/app/src/components/Me/ProfileImageSettings.tsx

@@ -91,9 +91,9 @@ const ProfileImageSettings = (): JSX.Element => {
 
 
   return (
   return (
     <>
     <>
-      <div className="row">
-        <div className="col-md-6 col-12 mb-3 mb-md-0">
-          <h4>
+      <div className="row justify-content-around mt-5 mt-md-4">
+        <div className="col-md-3">
+          <h5>
             <div className="form-check radio-primary">
             <div className="form-check radio-primary">
               <input
               <input
                 type="radio"
                 type="radio"
@@ -105,18 +105,18 @@ const ProfileImageSettings = (): JSX.Element => {
                 onChange={() => setGravatarEnabled(true)}
                 onChange={() => setGravatarEnabled(true)}
               />
               />
               <label className="form-label form-check-label" htmlFor="radioGravatar">
               <label className="form-label form-check-label" htmlFor="radioGravatar">
-                <img src={GRAVATAR_DEFAULT} data-vrt-blackout-profile /> Gravatar
+                <img src={GRAVATAR_DEFAULT} className="me-1" data-vrt-blackout-profile /> Gravatar
               </label>
               </label>
-              <a href="https://gravatar.com/">
-                <small><i className="icon-arrow-right-circle" aria-hidden="true"></i></small>
+              <a href="https://gravatar.com/" target="_blank" rel="noopener noreferrer">
+                <small><span className="material-symbols-outlined ms-2 text-secondary" aria-hidden="true">info</span></small>
               </a>
               </a>
             </div>
             </div>
-          </h4>
-          <img src={generateGravatarSrc(currentUser.email)} width="64" data-vrt-blackout-profile />
+          </h5>
+          <img src={generateGravatarSrc(currentUser.email)} className="rounded-pill" width="64" data-vrt-blackout-profile />
         </div>
         </div>
 
 
-        <div className="col-md-6 col-12">
-          <h4>
+        <div className="col-md-7 mt-5">
+          <h5>
             <div className="form-check radio-primary">
             <div className="form-check radio-primary">
               <input
               <input
                 type="radio"
                 type="radio"
@@ -131,21 +131,21 @@ const ProfileImageSettings = (): JSX.Element => {
                 { t('Upload Image') }
                 { t('Upload Image') }
               </label>
               </label>
             </div>
             </div>
-          </h4>
-          <div className="row mb-3">
-            <label className="col-sm-4 col-12 col-form-label text-start">
+          </h5>
+          <div className="row mt-3">
+            <label className="col-md-6 col-lg-4 col-form-label text-start">
               { t('Current Image') }
               { t('Current Image') }
             </label>
             </label>
-            <div className="col-sm-8 col-12">
-              <p><img src={uploadedPictureSrc ?? DEFAULT_IMAGE} className="picture picture-lg rounded-circle" id="settingUserPicture" /></p>
+            <div className="col-md-6 col-lg-8">
+              <p className="mb-0"><img src={uploadedPictureSrc ?? DEFAULT_IMAGE} className="picture picture-lg rounded-circle" id="settingUserPicture" /></p>
               {uploadedPictureSrc && <button type="button" className="btn btn-danger" onClick={deleteImageHandler}>{ t('Delete Image') }</button>}
               {uploadedPictureSrc && <button type="button" className="btn btn-danger" onClick={deleteImageHandler}>{ t('Delete Image') }</button>}
             </div>
             </div>
           </div>
           </div>
-          <div className="row">
-            <label className="col-sm-4 col-12 col-form-label text-start">
+          <div className="row align-items-center mt-3 mt-md-5">
+            <label className="col-md-6 col-lg-4 col-form-label text-start mt-3 mt-md-0">
               {t('Upload new image')}
               {t('Upload new image')}
             </label>
             </label>
-            <div className="col-sm-8 col-12">
+            <div className="col-md-6 col-lg-8">
               <input type="file" onChange={selectFileHandler} name="profileImage" accept="image/*" />
               <input type="file" onChange={selectFileHandler} name="profileImage" accept="image/*" />
             </div>
             </div>
           </div>
           </div>
@@ -161,7 +161,7 @@ const ProfileImageSettings = (): JSX.Element => {
         showCropOption
         showCropOption
       />
       />
 
 
-      <div className="row my-3">
+      <div className="row mt-4">
         <div className="offset-4 col-5">
         <div className="offset-4 col-5">
           <button type="button" className="btn btn-primary" onClick={submit}>
           <button type="button" className="btn btn-primary" onClick={submit}>
             {t('Update')}
             {t('Update')}

+ 2 - 2
apps/app/src/components/Me/UserSettings.tsx

@@ -11,11 +11,11 @@ const UserSettings = React.memo((): JSX.Element => {
   return (
   return (
     <div data-testid="grw-user-settings">
     <div data-testid="grw-user-settings">
       <div className="mb-5">
       <div className="mb-5">
-        <h2 className="border-bottom my-4">{t('Basic Info')}</h2>
+        <h2 className="border-bottom fs-4 mt-4 pb-1">{t('Basic Info')}</h2>
         <BasicInfoSettings />
         <BasicInfoSettings />
       </div>
       </div>
       <div className="mb-5">
       <div className="mb-5">
-        <h2 className="border-bottom my-4">{t('Set Profile Image')}</h2>
+        <h2 className="border-bottom fs-4 mt-3 mt-md-5 pb-1">{t('Set Profile Image')}</h2>
         <ProfileImageSettings />
         <ProfileImageSettings />
       </div>
       </div>
     </div>
     </div>

+ 6 - 6
apps/app/src/components/PageDuplicateModal.tsx

@@ -160,10 +160,10 @@ const PageDuplicateModal = (): JSX.Element => {
 
 
     return (
     return (
       <>
       <>
-        <div><label className="form-label">{t('modal_duplicate.label.Current page name')}</label><br />
+        <div className="mt-3"><label className="form-label">{t('modal_duplicate.label.Current page name')}</label><br />
           <code>{path}</code>
           <code>{path}</code>
         </div>
         </div>
-        <div>
+        <div className="mt-3">
           <label className="form-label" htmlFor="duplicatePageName">{ t('modal_duplicate.label.New page name') }</label><br />
           <label className="form-label" htmlFor="duplicatePageName">{ t('modal_duplicate.label.New page name') }</label><br />
           <div className="input-group">
           <div className="input-group">
             <div>
             <div>
@@ -196,7 +196,7 @@ const PageDuplicateModal = (): JSX.Element => {
           <p className="text-danger">Error: Target path is duplicated.</p>
           <p className="text-danger">Error: Target path is duplicated.</p>
         ) }
         ) }
 
 
-        <div className="form-check form-check-warning">
+        <div className="form-check form-check-warning mt-3">
           <input
           <input
             className="form-check-input"
             className="form-check-input"
             name="recursively"
             name="recursively"
@@ -210,7 +210,7 @@ const PageDuplicateModal = (): JSX.Element => {
             <p className="form-text text-muted my-0">{ t('modal_duplicate.help.recursive') }</p>
             <p className="form-text text-muted my-0">{ t('modal_duplicate.help.recursive') }</p>
           </label>
           </label>
 
 
-          <div>
+          <div className="mt-3">
             {isDuplicateRecursively && existingPaths.length !== 0 && (
             {isDuplicateRecursively && existingPaths.length !== 0 && (
               <div className="form-check form-check-warning">
               <div className="form-check form-check-warning">
                 <input
                 <input
@@ -230,7 +230,7 @@ const PageDuplicateModal = (): JSX.Element => {
           </div>
           </div>
         </div>
         </div>
 
 
-        <div className="form-check form-check-warning mb-3">
+        <div className="form-check form-check-warning mt-2">
           <input
           <input
             className="form-check-input"
             className="form-check-input"
             id="cbOnlyDuplicateUserRelatedResources"
             id="cbOnlyDuplicateUserRelatedResources"
@@ -243,7 +243,7 @@ const PageDuplicateModal = (): JSX.Element => {
             <p className="form-text text-muted my-0">{ t('modal_duplicate.help.only_user_related_resources') }</p>
             <p className="form-text text-muted my-0">{ t('modal_duplicate.help.only_user_related_resources') }</p>
           </label>
           </label>
         </div>
         </div>
-        <div>
+        <div className="mt-3">
           {isDuplicateRecursively && existingPaths.length !== 0 && (
           {isDuplicateRecursively && existingPaths.length !== 0 && (
             <DuplicatePathsTable existingPaths={existingPaths} fromPath={path} toPath={pageNameInput} />
             <DuplicatePathsTable existingPaths={existingPaths} fromPath={path} toPath={pageNameInput} />
           ) }
           ) }

+ 3 - 3
apps/app/src/pages/me/[[...path]].page.tsx

@@ -124,15 +124,15 @@ const MePage: NextPageWithLayout<Props> = (props: Props) => {
       </Head>
       </Head>
       <div className="dynamic-layout-root">
       <div className="dynamic-layout-root">
         <header className="py-3">
         <header className="py-3">
-          <div className="container-fluid">
-            <h1 className="title">{ targetPage.title }</h1>
+          <div className="container">
+            <h1 className="title fs-3 mt-5">{ targetPage.title }</h1>
           </div>
           </div>
         </header>
         </header>
 
 
         <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
         <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
 
 
         <div id="main" className="main">
         <div id="main" className="main">
-          <div id="content-main" className="content-main container-lg">
+          <div id="content-main" className="content-main container">
             {targetPage.component}
             {targetPage.component}
           </div>
           </div>
         </div>
         </div>

+ 8 - 6
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -3,19 +3,20 @@ import type {
 } from '@growi/core';
 } from '@growi/core';
 import { isIPageInfoForEntity } from '@growi/core';
 import { isIPageInfoForEntity } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
-import express, { Request, Router } from 'express';
+import type { Request, Router } from 'express';
+import express from 'express';
 import { query, oneOf } from 'express-validator';
 import { query, oneOf } from 'express-validator';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 
 
-import { IPageGrantService } from '~/server/service/page-grant';
+import type { IPageGrantService } from '~/server/service/page-grant';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import Crowi from '../../crowi';
+import type Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-import { PageModel } from '../../models/page';
+import type { PageModel } from '../../models/page';
 
 
-import { ApiV3Response } from './interfaces/apiv3-response';
+import type { ApiV3Response } from './interfaces/apiv3-response';
 
 
 const logger = loggerFactory('growi:routes:apiv3:page-tree');
 const logger = loggerFactory('growi:routes:apiv3:page-tree');
 
 
@@ -149,7 +150,8 @@ const routerFactory = (crowi: Crowi): Router => {
         // construct isIPageInfoForListing
         // construct isIPageInfoForListing
         const basicPageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
         const basicPageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
 
 
-        const canDeleteCompletely = pageService.canDeleteCompletely(page, req.user, false, userRelatedGroups); // use normal delete config
+        // TODO: use pageService.getCreatorIdForCanDelete to get creatorId (https://redmine.weseek.co.jp/issues/140574)
+        const canDeleteCompletely = pageService.canDeleteCompletely(page, page.creator, req.user, false, userRelatedGroups); // use normal delete config
 
 
         const pageInfo = (!isIPageInfoForEntity(basicPageInfo))
         const pageInfo = (!isIPageInfoForEntity(basicPageInfo))
           ? basicPageInfo
           ? basicPageInfo

+ 4 - 15
apps/app/src/server/routes/page.js

@@ -366,25 +366,14 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Empty pages cannot be single deleted', 'single_deletion_empty_pages'));
       return res.json(ApiResponse.error('Empty pages cannot be single deleted', 'single_deletion_empty_pages'));
     }
     }
 
 
-    // -- canDelete no longer needs creator,
-    //  however it might be required to retrieve the closest non-empty ancestor page's owner -- 2024.02.09 Yuki Takei
-    //
-    // let creator;
-    // if (page.isEmpty) {
-    //   // If empty, the creator is inherited from the closest non-empty ancestor page.
-    //   const notEmptyClosestAncestor = await Page.findNonEmptyClosestAncestor(page.path);
-    //   creator = notEmptyClosestAncestor.creator;
-    // }
-    // else {
-    //   creator = page.creator;
-    // }
+    const creatorId = await crowi.pageService.getCreatorIdForCanDelete(page);
 
 
     debug('Delete page', page._id, page.path);
     debug('Delete page', page._id, page.path);
 
 
     try {
     try {
       if (isCompletely) {
       if (isCompletely) {
         const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(req.user);
         const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(req.user);
-        const canDeleteCompletely = crowi.pageService.canDeleteCompletely(page, req.user, isRecursively, userRelatedGroups);
+        const canDeleteCompletely = crowi.pageService.canDeleteCompletely(page, creatorId, req.user, isRecursively, userRelatedGroups);
         if (!canDeleteCompletely) {
         if (!canDeleteCompletely) {
           return res.json(ApiResponse.error('You cannot delete this page completely', 'complete_deletion_not_allowed_for_user'));
           return res.json(ApiResponse.error('You cannot delete this page completely', 'complete_deletion_not_allowed_for_user'));
         }
         }
@@ -411,8 +400,8 @@ module.exports = function(crowi, app) {
           return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
           return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
         }
         }
 
 
-        if (!crowi.pageService.canDelete(page, req.user, isRecursively)) {
-          return res.json(ApiResponse.error('You can not delete this page', 'user_not_admin'));
+        if (!crowi.pageService.canDelete(page, creatorId, req.user, isRecursively)) {
+          return res.json(ApiResponse.error('You cannot delete this page', 'user_not_admin'));
         }
         }
 
 
         if (pagePathUtils.isUsersHomepage(page.path)) {
         if (pagePathUtils.isUsersHomepage(page.path)) {

+ 35 - 14
apps/app/src/server/service/page/index.ts

@@ -200,13 +200,15 @@ class PageService implements IPageService {
 
 
   /**
   /**
    * Check if page can be deleted completely.
    * Check if page can be deleted completely.
-   * Use pageGrantService.getUserRelatedGroups before execution of canDeleteCompletely to get value for userRelatedGroups.
-   * Do NOT use getUserRelatedGrantedGroups inside this method, because canDeleteCompletely should not be async as for now.
-   * The reason for this is because canDeleteCompletely is called in /page-listing/info in a for loop,
+   * Use the following methods before execution of canDeleteCompletely to get params.
+   *   - pageService.getCreatorIdForCanDelete: creatorId
+   *   - pageGrantService.getUserRelatedGroups: userRelatedGroups
+   * Do NOT make this method async as for now, because canDeleteCompletely is called in /page-listing/info in a for loop,
    * and /page-listing/info should not be an execution heavy API.
    * and /page-listing/info should not be an execution heavy API.
    */
    */
   canDeleteCompletely(
   canDeleteCompletely(
       page: PageDocument,
       page: PageDocument,
+      creatorId: ObjectIdLike | null,
       operator: any | null,
       operator: any | null,
       isRecursively: boolean,
       isRecursively: boolean,
       userRelatedGroups: PopulatedGrantedGroup[],
       userRelatedGroups: PopulatedGrantedGroup[],
@@ -216,25 +218,28 @@ class PageService implements IPageService {
     const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
     const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
     const pageRecursiveCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority');
     const pageRecursiveCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority');
 
 
-    if (!this.canDeleteCompletelyAsMultiGroupGrantedPage(page, operator, userRelatedGroups)) return false;
+    if (!this.canDeleteCompletelyAsMultiGroupGrantedPage(page, creatorId, operator, userRelatedGroups)) return false;
 
 
     const [singleAuthority, recursiveAuthority] = prepareDeleteConfigValuesForCalc(pageCompleteDeletionAuthority, pageRecursiveCompleteDeletionAuthority);
     const [singleAuthority, recursiveAuthority] = prepareDeleteConfigValuesForCalc(pageCompleteDeletionAuthority, pageRecursiveCompleteDeletionAuthority);
 
 
-    return this.canDeleteLogic(page.creator, operator, isRecursively, singleAuthority, recursiveAuthority);
+    return this.canDeleteLogic(creatorId, operator, isRecursively, singleAuthority, recursiveAuthority);
   }
   }
 
 
   /**
   /**
    * If page is multi-group granted, check if operator is allowed to completely delete the page.
    * If page is multi-group granted, check if operator is allowed to completely delete the page.
    * see: https://dev.growi.org/656745fa52eafe1cf1879508#%E5%AE%8C%E5%85%A8%E3%81%AB%E5%89%8A%E9%99%A4%E3%81%99%E3%82%8B%E6%93%8D%E4%BD%9C
    * see: https://dev.growi.org/656745fa52eafe1cf1879508#%E5%AE%8C%E5%85%A8%E3%81%AB%E5%89%8A%E9%99%A4%E3%81%99%E3%82%8B%E6%93%8D%E4%BD%9C
+   * creatorId must be obtained by getCreatorIdForCanDelete
    */
    */
-  canDeleteCompletelyAsMultiGroupGrantedPage(page: PageDocument, operator: any | null, userRelatedGroups: PopulatedGrantedGroup[]): boolean {
+  canDeleteCompletelyAsMultiGroupGrantedPage(
+      page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, userRelatedGroups: PopulatedGrantedGroup[],
+  ): boolean {
     const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
     const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
     const isAllGroupMembershipRequiredForPageCompleteDeletion = this.crowi.configManager.getConfig(
     const isAllGroupMembershipRequiredForPageCompleteDeletion = this.crowi.configManager.getConfig(
       'crowi', 'security:isAllGroupMembershipRequiredForPageCompleteDeletion',
       'crowi', 'security:isAllGroupMembershipRequiredForPageCompleteDeletion',
     );
     );
 
 
     const isAdmin = operator?.admin ?? false;
     const isAdmin = operator?.admin ?? false;
-    const isAuthor = operator?._id == null ? false : operator._id.equals(page.creator);
+    const isAuthor = operator?._id == null ? false : operator._id.equals(creatorId);
     const isAdminOrAuthor = isAdmin || isAuthor;
     const isAdminOrAuthor = isAdmin || isAuthor;
 
 
     if (page.grant === PageGrant.GRANT_USER_GROUP
     if (page.grant === PageGrant.GRANT_USER_GROUP
@@ -249,7 +254,19 @@ class PageService implements IPageService {
     return true;
     return true;
   }
   }
 
 
-  canDelete(page: PageDocument, operator: any | null, isRecursively: boolean): boolean {
+  // When page is empty, the 'canDelete' judgement should be done using the creator of the closest non-empty ancestor page.
+  async getCreatorIdForCanDelete(page: PageDocument): Promise<ObjectIdLike | null> {
+    if (page.isEmpty) {
+      const Page = mongoose.model<IPage, PageModel>('Page');
+      const notEmptyClosestAncestor = await Page.findNonEmptyClosestAncestor(page.path);
+      return notEmptyClosestAncestor?.creator ?? null;
+    }
+
+    return page.creator ?? null;
+  }
+
+  // Use getCreatorIdForCanDelete before execution of canDelete to get creatorId.
+  canDelete(page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, isRecursively: boolean): boolean {
     if (operator == null || isTopPage(page.path) || isUsersTopPage(page.path)) return false;
     if (operator == null || isTopPage(page.path) || isUsersTopPage(page.path)) return false;
 
 
     const pageDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageDeletionAuthority');
     const pageDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageDeletionAuthority');
@@ -257,7 +274,7 @@ class PageService implements IPageService {
 
 
     const [singleAuthority, recursiveAuthority] = prepareDeleteConfigValuesForCalc(pageDeletionAuthority, pageRecursiveDeletionAuthority);
     const [singleAuthority, recursiveAuthority] = prepareDeleteConfigValuesForCalc(pageDeletionAuthority, pageRecursiveDeletionAuthority);
 
 
-    return this.canDeleteLogic(page.creator, operator, isRecursively, singleAuthority, recursiveAuthority);
+    return this.canDeleteLogic(creatorId, operator, isRecursively, singleAuthority, recursiveAuthority);
   }
   }
 
 
   canDeleteUserHomepageByConfig(): boolean {
   canDeleteUserHomepageByConfig(): boolean {
@@ -275,7 +292,7 @@ class PageService implements IPageService {
   }
   }
 
 
   private canDeleteLogic(
   private canDeleteLogic(
-      creatorId: ObjectIdLike,
+      creatorId: ObjectIdLike | null,
       operator,
       operator,
       isRecursively: boolean,
       isRecursively: boolean,
       authority: IPageDeleteConfigValueToProcessValidation | null,
       authority: IPageDeleteConfigValueToProcessValidation | null,
@@ -329,12 +346,14 @@ class PageService implements IPageService {
       pages: PageDocument[],
       pages: PageDocument[],
       user: IUserHasId,
       user: IUserHasId,
       isRecursively: boolean,
       isRecursively: boolean,
-      canDeleteFunction: (page: PageDocument, operator: any, isRecursively: boolean, userRelatedGroups: PopulatedGrantedGroup[]) => boolean,
+      canDeleteFunction: (
+        page: PageDocument, creatorId: ObjectIdLike, operator: any, isRecursively: boolean, userRelatedGroups: PopulatedGrantedGroup[]
+      ) => boolean,
   ): Promise<PageDocument[]> {
   ): Promise<PageDocument[]> {
     const userRelatedGroups = await this.pageGrantService.getUserRelatedGroups(user);
     const userRelatedGroups = await this.pageGrantService.getUserRelatedGroups(user);
     const filteredPages = pages.filter(async(p) => {
     const filteredPages = pages.filter(async(p) => {
       if (p.isEmpty) return true;
       if (p.isEmpty) return true;
-      const canDelete = canDeleteFunction(p, user, isRecursively, userRelatedGroups);
+      const canDelete = canDeleteFunction(p, p.creator, user, isRecursively, userRelatedGroups);
       return canDelete;
       return canDelete;
     });
     });
 
 
@@ -421,10 +440,12 @@ class PageService implements IPageService {
 
 
     const subscription = await Subscription.findByUserIdAndTargetId(user._id, pageId);
     const subscription = await Subscription.findByUserIdAndTargetId(user._id, pageId);
 
 
+    const creatorId = await this.getCreatorIdForCanDelete(page);
+
     const userRelatedGroups = await this.pageGrantService.getUserRelatedGroups(user);
     const userRelatedGroups = await this.pageGrantService.getUserRelatedGroups(user);
 
 
-    const isDeletable = this.canDelete(page, user, false);
-    const isAbleToDeleteCompletely = this.canDeleteCompletely(page, user, false, userRelatedGroups); // use normal delete config
+    const isDeletable = this.canDelete(page, creatorId, user, false);
+    const isAbleToDeleteCompletely = this.canDeleteCompletely(page, creatorId, user, false, userRelatedGroups); // use normal delete config
 
 
     return {
     return {
       data: page,
       data: page,

+ 7 - 3
apps/app/src/server/service/page/page-service.ts

@@ -23,7 +23,11 @@ export interface IPageService {
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>,
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>,
   shortBodiesMapByPageIds(pageIds?: ObjectId[], user?): Promise<Record<string, string | null>>,
   shortBodiesMapByPageIds(pageIds?: ObjectId[], user?): Promise<Record<string, string | null>>,
   constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity,
   constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity,
-  canDelete(page: PageDocument, operator: any | null, isRecursively: boolean): boolean,
-  canDeleteCompletely(page: PageDocument, operator: any | null, isRecursively: boolean, userRelatedGroups: PopulatedGrantedGroup[]): boolean,
-  canDeleteCompletelyAsMultiGroupGrantedPage(page: PageDocument, operator: any | null, userRelatedGroups: PopulatedGrantedGroup[]): boolean,
+  canDelete(page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, isRecursively: boolean): boolean,
+  canDeleteCompletely(
+    page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, isRecursively: boolean, userRelatedGroups: PopulatedGrantedGroup[]
+  ): boolean,
+  canDeleteCompletelyAsMultiGroupGrantedPage(
+    page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, userRelatedGroups: PopulatedGrantedGroup[]
+  ): boolean,
 }
 }

+ 8 - 4
apps/app/test/integration/service/page.test.js

@@ -769,8 +769,9 @@ describe('PageService', () => {
         });
         });
 
 
         test('is not deletable', async() => {
         test('is not deletable', async() => {
+          const creatorId = await crowi.pageService.getCreatorIdForCanDelete(canDeleteCompletelyTestPage);
           const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(testUser1);
           const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(testUser1);
-          const isDeleteable = crowi.pageService.canDeleteCompletely(canDeleteCompletelyTestPage, testUser1, false, userRelatedGroups);
+          const isDeleteable = crowi.pageService.canDeleteCompletely(canDeleteCompletelyTestPage, creatorId, testUser1, false, userRelatedGroups);
           expect(isDeleteable).toBe(false);
           expect(isDeleteable).toBe(false);
         });
         });
       });
       });
@@ -789,8 +790,9 @@ describe('PageService', () => {
         });
         });
 
 
         test('is not deletable', async() => {
         test('is not deletable', async() => {
+          const creatorId = await crowi.pageService.getCreatorIdForCanDelete(canDeleteCompletelyTestPage);
           const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(testUser3);
           const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(testUser3);
-          const isDeleteable = crowi.pageService.canDeleteCompletely(canDeleteCompletelyTestPage, testUser3, false, userRelatedGroups);
+          const isDeleteable = crowi.pageService.canDeleteCompletely(canDeleteCompletelyTestPage, creatorId, testUser3, false, userRelatedGroups);
           expect(isDeleteable).toBe(true);
           expect(isDeleteable).toBe(true);
         });
         });
       });
       });
@@ -809,8 +811,9 @@ describe('PageService', () => {
         });
         });
 
 
         test('is deletable', async() => {
         test('is deletable', async() => {
+          const creatorId = await crowi.pageService.getCreatorIdForCanDelete(canDeleteCompletelyTestPage);
           const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(testUser1);
           const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(testUser1);
-          const isDeleteable = crowi.pageService.canDeleteCompletely(canDeleteCompletelyTestPage, testUser1, false, userRelatedGroups);
+          const isDeleteable = crowi.pageService.canDeleteCompletely(canDeleteCompletelyTestPage, creatorId, testUser1, false, userRelatedGroups);
           expect(isDeleteable).toBe(true);
           expect(isDeleteable).toBe(true);
         });
         });
       });
       });
@@ -829,8 +832,9 @@ describe('PageService', () => {
         });
         });
 
 
         test('is deletable', async() => {
         test('is deletable', async() => {
+          const creatorId = await crowi.pageService.getCreatorIdForCanDelete(canDeleteCompletelyTestPage);
           const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(testUser2);
           const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(testUser2);
-          const isDeleteable = crowi.pageService.canDeleteCompletely(canDeleteCompletelyTestPage, testUser2, false, userRelatedGroups);
+          const isDeleteable = crowi.pageService.canDeleteCompletely(canDeleteCompletelyTestPage, creatorId, testUser2, false, userRelatedGroups);
           expect(isDeleteable).toBe(true);
           expect(isDeleteable).toBe(true);
         });
         });
       });
       });

+ 15 - 5
packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx

@@ -10,7 +10,7 @@ import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 
 
 import { GlobalCodeMirrorEditorKey } from '../../consts';
 import { GlobalCodeMirrorEditorKey } from '../../consts';
 import {
 import {
-  useFileDropzone, FileDropzoneOverlay, getEditorTheme, type EditorTheme, getKeyMap, type KeyMapMode,
+  useFileDropzone, FileDropzoneOverlay, getEditorTheme, type EditorTheme, getKeymap, type KeyMapMode,
 } from '../../services';
 } from '../../services';
 import {
 import {
   adjustPasteData, getStrFromBol,
   adjustPasteData, getStrFromBol,
@@ -169,15 +169,25 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
   }, [codeMirrorEditor, themeExtension]);
   }, [codeMirrorEditor, themeExtension]);
 
 
 
 
+  const [keymapExtension, setKeymapExtension] = useState<Extension | undefined>(undefined);
   useEffect(() => {
   useEffect(() => {
-    const keymap = (editorKeymap ?? 'default') as KeyMapMode;
-    const extension = getKeyMap(keymap, onSave);
+    const settingKeyMap = async(name?: KeyMapMode) => {
+      setKeymapExtension(await getKeymap(name ?? 'default'));
+    };
+    settingKeyMap(editorKeymap as KeyMapMode);
+
+  }, [codeMirrorEditor, editorKeymap, setKeymapExtension]);
+
+  useEffect(() => {
+    if (keymapExtension == null) {
+      return;
+    }
 
 
     // Prevent these Keybind from overwriting the originally defined keymap.
     // Prevent these Keybind from overwriting the originally defined keymap.
-    const cleanupFunction = codeMirrorEditor?.appendExtensions(Prec.low(extension));
+    const cleanupFunction = codeMirrorEditor?.appendExtensions(Prec.low(keymapExtension));
     return cleanupFunction;
     return cleanupFunction;
 
 
-  }, [codeMirrorEditor, editorKeymap, onSave]);
+  }, [codeMirrorEditor, keymapExtension, onSave]);
 
 
   const {
   const {
     getRootProps,
     getRootProps,

+ 5 - 10
packages/editor/src/services/keymaps/index.ts

@@ -1,22 +1,17 @@
-import { defaultKeymap } from '@codemirror/commands';
 import { Extension } from '@codemirror/state';
 import { Extension } from '@codemirror/state';
 import { keymap } from '@codemirror/view';
 import { keymap } from '@codemirror/view';
-import { emacs } from '@replit/codemirror-emacs';
-import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
 
 
-import { vimKeymap } from './vim';
 
 
-
-export const getKeyMap = (keyMapName: KeyMapMode, onSave?: () => void): Extension => {
+export const getKeymap = async(keyMapName: KeyMapMode, onSave?: () => void): Promise<Extension> => {
   switch (keyMapName) {
   switch (keyMapName) {
     case 'vim':
     case 'vim':
-      return vimKeymap(onSave);
+      return (await import('./vim')).vimKeymap(onSave);
     case 'emacs':
     case 'emacs':
-      return emacs();
+      return (await import('@replit/codemirror-emacs')).emacs();
     case 'vscode':
     case 'vscode':
-      return keymap.of(vscodeKeymap);
+      return keymap.of((await import('@replit/codemirror-vscode-keymap')).vscodeKeymap);
     case 'default':
     case 'default':
-      return keymap.of(defaultKeymap);
+      return keymap.of((await import('@codemirror/commands')).defaultKeymap);
   }
   }
 };
 };
 
 

+ 6 - 6
packages/preset-themes/src/styles/default.scss

@@ -9,7 +9,7 @@
   @include generate-color-palette('primary', $primary, #000010, white, 22%, 22%);
   @include generate-color-palette('primary', $primary, #000010, white, 22%, 22%);
   @include generate-color-palette('highlight', $highlight);
   @include generate-color-palette('highlight', $highlight);
 
 
-  $body-color:                #223246;
+  $body-color:                $gray-800;
   $body-bg:                   white;
   $body-bg:                   white;
 
 
   $body-secondary-color:      rgba($body-color, .75);
   $body-secondary-color:      rgba($body-color, .75);
@@ -29,8 +29,8 @@
 
 
   @import '@growi/core/scss/bootstrap/theming/apply-light';
   @import '@growi/core/scss/bootstrap/theming/apply-light';
 
 
-  --grw-wiki-link-color-rgb: var(--grw-highlight-800-rgb);
-  --grw-wiki-link-hover-color-rgb: var(--grw-highlight-900-rgb);
+  --grw-wiki-link-color-rgb: var(--grw-highlight-700-rgb);
+  --grw-wiki-link-hover-color-rgb: var(--grw-highlight-600-rgb);
   --grw-sidebar-nav-btn-color: var(--grw-highlight-600);
   --grw-sidebar-nav-btn-color: var(--grw-highlight-600);
 }
 }
 
 
@@ -43,7 +43,7 @@
   $highlight: #c4c2bd;
   $highlight: #c4c2bd;
 
 
   @include generate-color-palette('primary', $primary, #000010, white, 22%, 22%);
   @include generate-color-palette('primary', $primary, #000010, white, 22%, 22%);
-  @include generate-color-palette('highlight', $highlight, black, white);
+  @include generate-color-palette('highlight', $highlight, black, white, 22%, 22%);
 
 
   $body-color-dark:                   $gray-300;
   $body-color-dark:                   $gray-300;
   $body-bg-dark:                      #1c1a1a;
   $body-bg-dark:                      #1c1a1a;
@@ -65,8 +65,8 @@
 
 
   @import '@growi/core/scss/bootstrap/theming/apply-dark';
   @import '@growi/core/scss/bootstrap/theming/apply-dark';
 
 
-  --grw-wiki-link-color-rgb: var(--grw-highlight-500-rgb);
-  --grw-wiki-link-hover-color-rgb: var(--grw-highlight-300-rgb);
+  --grw-wiki-link-color-rgb: var(--grw-highlight-600-rgb);
+  --grw-wiki-link-hover-color-rgb: var(--grw-highlight-400-rgb);
   --grw-sidebar-nav-btn-color: rgba(var(--grw-highlight-400-rgb), 0.8);
   --grw-sidebar-nav-btn-color: rgba(var(--grw-highlight-400-rgb), 0.8);
 }
 }