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

Merge branch 'feat/gw7925-normalize-cypress-test' into feat/115447-119721-update-dnd-root-move

ryoji-s 2 лет назад
Родитель
Сommit
de6df354d7
41 измененных файлов с 580 добавлено и 180 удалено
  1. 3 2
      apps/app/docker/Dockerfile
  2. 2 0
      apps/app/docker/Dockerfile.dockerignore
  3. 1 2
      apps/app/public/static/locales/en_US/admin.json
  4. 2 1
      apps/app/public/static/locales/en_US/commons.json
  5. 3 2
      apps/app/public/static/locales/en_US/translation.json
  6. 1 2
      apps/app/public/static/locales/ja_JP/admin.json
  7. 2 1
      apps/app/public/static/locales/ja_JP/commons.json
  8. 2 1
      apps/app/public/static/locales/ja_JP/translation.json
  9. 1 2
      apps/app/public/static/locales/zh_CN/admin.json
  10. 2 1
      apps/app/public/static/locales/zh_CN/commons.json
  11. 2 1
      apps/app/public/static/locales/zh_CN/translation.json
  12. 12 14
      apps/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  13. 21 9
      apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx
  14. 17 6
      apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx
  15. 14 21
      apps/app/src/components/Bookmarks/BookmarkFolderMenuItem.tsx
  16. 5 1
      apps/app/src/components/Fab.tsx
  17. 20 3
      apps/app/src/components/InAppNotification/InAppNotificationElm.tsx
  18. 46 0
      apps/app/src/components/InAppNotification/PageNotification/UserModelNotification.tsx
  19. 1 1
      apps/app/src/components/Navbar/GrowiSubNavigationSwitcher.tsx
  20. 33 14
      apps/app/src/components/PasswordResetRequestForm.tsx
  21. 6 0
      apps/app/src/interfaces/activity.ts
  22. 8 4
      apps/app/src/interfaces/in-app-notification.ts
  23. 15 0
      apps/app/src/models/serializers/in-app-notification-snapshot/user.ts
  24. 20 7
      apps/app/src/pages/forgot-password.page.tsx
  25. 18 3
      apps/app/src/server/models/bookmark-folder.ts
  26. 7 2
      apps/app/src/server/routes/apiv3/bookmark-folder.ts
  27. 2 2
      apps/app/src/server/routes/attachment.js
  28. 19 8
      apps/app/src/server/routes/login.js
  29. 3 1
      apps/app/src/server/service/file-uploader/gcs.js
  30. 19 8
      apps/app/src/server/service/in-app-notification.ts
  31. 5 6
      apps/app/src/server/service/page.ts
  32. 8 2
      apps/app/src/server/service/search.ts
  33. 2 1
      apps/app/src/services/renderer/renderer.tsx
  34. 13 2
      apps/app/src/stores/in-app-notification.ts
  35. 137 0
      apps/app/test/cypress/integration/20-basic-features/20-basic-features--sticky-features.spec.ts
  36. 58 0
      apps/app/test/cypress/integration/21-basic-features-for-guest/21-basic-features-for-guest--sticky-for-guest.spec.ts
  37. 2 4
      apps/app/test/integration/service/page.test.js
  38. 42 44
      apps/slackbot-proxy/docker/Dockerfile
  39. 3 0
      apps/slackbot-proxy/docker/Dockerfile.dockerignore
  40. 1 1
      packages/core/src/models/devided-page-path.js
  41. 2 1
      turbo.json

+ 3 - 2
apps/app/docker/Dockerfile

@@ -90,13 +90,14 @@ RUN tar -cf packages.tar \
   package.json \
   apps/app/.next \
   apps/app/config \
+  apps/app/dist \
   apps/app/public \
   apps/app/resource \
   apps/app/tmp \
   apps/app/.env.production* \
   apps/app/next.config.js \
-  **/package.json \
-  **/dist
+  packages/*/package.json \
+  packages/*/dist
 
 
 

+ 2 - 0
apps/app/docker/Dockerfile.dockerignore

@@ -5,3 +5,5 @@
 **/*.dockerignore
 **/.next
 **/.turbo
+out
+apps/slackbot-proxy

+ 1 - 2
apps/app/public/static/locales/en_US/admin.json

@@ -106,8 +106,7 @@
       "password_reset_desc": "when forgot password, users are able to reset it by themselves.",
       "email_authentication": "Email authentication on user registration",
       "enable_email_authentication": "Enable email authentication",
-      "enable_email_authentication_desc": "Email authentication is going to be performed for user registration.",
-      "need_complete_mail_setting_warning": "To use the following functions, please complete the mail settings."
+      "enable_email_authentication_desc": "Email authentication is going to be performed for user registration."
     },
     "ldap": {
       "enable_ldap": "Enable LDAP",

+ 2 - 1
apps/app/public/static/locales/en_US/commons.json

@@ -21,7 +21,8 @@
   },
   "alert": {
     "siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the {{link}}",
-    "please_enable_mailer": "Please setup mailer first."
+    "please_enable_mailer": "Please setup mailer first.",
+    "password_reset_please_enable_mailer": "Please setup mailer first."
   },
   "headers": {
     "app_settings": "App Settings"

+ 3 - 2
apps/app/public/static/locales/en_US/translation.json

@@ -587,7 +587,7 @@
   "login": {
     "title": "Login",
     "sign_in_error": "Login error",
-    "registration_successful": "registration_successful. Please wait for administrator approval.",
+    "registration_successful": "Registration successful. Please wait for administrator approval.",
     "Setup": "Setup",
     "enabled_ldap_has_configuration_problem":"LDAP is enabled but the configuration has something wrong.",
     "set_env_var_for_logs": "(Please set the environment variables <code>DEBUG=crowi:service:PassportService</code> to get the logs)"
@@ -674,7 +674,8 @@
     "success_to_send_email": "Success to send email",
     "feature_is_unavailable": "This feature is unavailable.",
     "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired. Please resend a password reset request via the link below.",
-    "password_and_confirm_password_does_not_match": "Password and confirm password does not match"
+    "password_and_confirm_password_does_not_match": "Password and confirm password does not match",
+    "please_enable_mailer_alert": "The password reset feature is disabled because email setup has not been completed. Please ask administrator to complete the email setup."
   },
   "emoji" :{
     "title": "Pick an Emoji",

+ 1 - 2
apps/app/public/static/locales/ja_JP/admin.json

@@ -114,8 +114,7 @@
       "password_reset_desc": "ログイン時のパスワードを忘れた際に、ユーザー自身がパスワードを再設定できます。",
       "email_authentication": "ユーザー登録時のメール認証",
       "enable_email_authentication": "メール認証を有効にする",
-      "enable_email_authentication_desc": "ユーザー登録時にメール認証を行います。",
-      "need_complete_mail_setting_warning": "以下の機能を使えるようにするには、メール設定を完了させてください。"
+      "enable_email_authentication_desc": "ユーザー登録時にメール認証を行います。"
     },
     "ldap": {
       "enable_ldap": "LDAP を有効にする",

+ 2 - 1
apps/app/public/static/locales/ja_JP/commons.json

@@ -21,7 +21,8 @@
   },
   "alert": {
     "siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",
-    "please_enable_mailer": "メール認証を有効にするには、メール設定を完了させてください。"
+    "please_enable_mailer": "メール認証を有効にするには、メール設定を完了させてください。",
+    "password_reset_please_enable_mailer": "パスワード再設定を有効にするには、メール設定を完了させてください。"
   },
   "headers": {
     "app_settings": "アプリ設定"

+ 2 - 1
apps/app/public/static/locales/ja_JP/translation.json

@@ -708,7 +708,8 @@
     "success_to_send_email": "メールを送信しました",
     "feature_is_unavailable": "この機能を利用することはできません。",
     "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。 以下のリンクからパスワードリセットリクエストを再送信してください。",
-    "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
+    "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません",
+    "please_enable_mailer_alert": "メール設定が完了していないため、パスワード再設定機能が無効になっています。メール設定を完了させるよう管理者に依頼してください。"
   },
   "emoji" :{
     "title": "絵文字を選択",

+ 1 - 2
apps/app/public/static/locales/zh_CN/admin.json

@@ -114,8 +114,7 @@
       "password_reset_desc": "忘记密码时,用户可以自行重置",
       "email_authentication": "用户注册时的电子邮件身份验证",
       "enable_email_authentication": "启用电子邮件身份验证",
-      "enable_email_authentication_desc": "用户注册将执行电子邮件身份验证。",
-      "need_complete_mail_setting_warning": "要使用以下功能,请完成邮件设置。"
+      "enable_email_authentication_desc": "用户注册将执行电子邮件身份验证。"
 		},
 		"ldap": {
 			"enable_ldap": "Enable LDAP",

+ 2 - 1
apps/app/public/static/locales/zh_CN/commons.json

@@ -21,7 +21,8 @@
   },
   "alert": {
     "siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",
-    "please_enable_mailer": "请先设置邮件程序。"
+    "please_enable_mailer": "请先设置邮件程序。",
+    "password_reset_please_enable_mailer": "请先设置邮件程序。"
   },
   "headers": {
     "app_settings": "系统设置"

+ 2 - 1
apps/app/public/static/locales/zh_CN/translation.json

@@ -678,7 +678,8 @@
     "success_to_send_email": "我发了一封电子邮件",
     "feature_is_unavailable": "此功能不可用",
     "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。 请通过以下链接重新发送密码重置请求",
-    "password_and_confirm_password_does_not_match": "密码和确认密码不匹配"
+    "password_and_confirm_password_does_not_match": "密码和确认密码不匹配",
+    "please_enable_mailer_alert": "密码重置功能被禁用,因为电子邮件设置尚未完成。请要求管理员完成电子邮件的设置。"
   },
   "emoji" :{
     "title": "选择一个表情符号",

+ 12 - 14
apps/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx

@@ -1,9 +1,9 @@
 import React from 'react';
 
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 import PropTypes from 'prop-types';
 
-
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminLocalSecurityContainer from '~/client/services/AdminLocalSecurityContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
@@ -52,17 +52,6 @@ class LocalSecuritySettingContents extends React.Component {
         )}
         <h2 className="alert-anchor border-bottom">{t('security_settings.Local.name')}</h2>
 
-        {!isMailerSetup && (
-          <div className="row">
-            <div className="col-12">
-              <div className="alert alert-danger">
-                <span>{t('security_settings.Local.need_complete_mail_setting_warning')}</span>
-                <a href="/admin/app#mail-settings"> <i className="fa fa-link"></i> {t('admin:app_setting.mail_settings')}</a>
-              </div>
-            </div>
-          </div>
-        )}
-
         {adminLocalSecurityContainer.state.useOnlyEnvVars && (
           <p
             className="alert alert-info"
@@ -146,7 +135,6 @@ class LocalSecuritySettingContents extends React.Component {
                     </button>
                   </div>
                 </div>
-
                 <p className="form-text text-muted small">{t('security_settings.register_limitation_desc')}</p>
               </div>
             </div>
@@ -189,6 +177,14 @@ class LocalSecuritySettingContents extends React.Component {
                     {t('security_settings.Local.enable_password_reset_by_users')}
                   </label>
                 </div>
+                {!isMailerSetup && (
+                  <div className="alert alert-warning p-1 my-1 small d-inline-block">
+                    <span>{t('commons:alert.password_reset_please_enable_mailer')}</span>
+                    <Link href="/admin/app#mail-settings">
+                      <i className="fa fa-link"></i> {t('app_setting.mail_settings')}
+                    </Link>
+                  </div>
+                )}
                 <p className="form-text text-muted small">
                   {t('security_settings.Local.password_reset_desc')}
                 </p>
@@ -213,7 +209,9 @@ class LocalSecuritySettingContents extends React.Component {
                 {!isMailerSetup && (
                   <div className="alert alert-warning p-1 my-1 small d-inline-block">
                     <span>{t('commons:alert.please_enable_mailer')}</span>
-                    <a href="/admin/app#mail-settings"> <i className="fa fa-link"></i> {t('app_setting.mail_settings')}</a>
+                    <Link href="/admin/app#mail-settings">
+                      <i className="fa fa-link"></i> {t('app_setting.mail_settings')}
+                    </Link>
                   </div>
                 )}
                 <p className="form-text text-muted small">

+ 21 - 9
apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx

@@ -152,11 +152,19 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     }
   };
 
-  const isDroppable = (item: DragItemDataType, type: string | null| symbol): boolean => {
+  const isDropable = (item: DragItemDataType, type: string | null| symbol): boolean => {
     if (type === DRAG_ITEM_TYPE.FOLDER) {
       if (item.bookmarkFolder.parent === bookmarkFolder._id || item.bookmarkFolder._id === bookmarkFolder._id) {
         return false;
       }
+
+      // Maximum folder hierarchy of 2 levels
+      // If the drop source folder has child folders, the drop source folder cannot be moved because the drop source folder hierarchy is already 2.
+      // If the destination folder has a parent, the source folder cannot be moved because the destination folder hierarchy is already 2.
+      if (item.bookmarkFolder.children.length !== 0 || bookmarkFolder.parent != null) {
+        return false;
+      }
+
       return item.root !== root || item.level >= level;
     }
 
@@ -237,7 +245,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
         useDragMode={true}
         useDropMode={true}
         onDropItem={itemDropHandler}
-        isDropable={isDroppable}
+        isDropable={isDropable}
       >
         <li
           className={'list-group-item list-group-item-action border-0 py-0 pr-3 d-flex align-items-center'}
@@ -288,13 +296,17 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
                 </DropdownToggle>
               </div>
             </BookmarkFolderItemControl>
-            <button
-              type="button"
-              className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
-              onClick={onClickPlusButton}
-            >
-              <i className="icon-plus d-block p-0" />
-            </button>
+            {/* Maximum folder hierarchy of 2 levels */}
+            {!(bookmarkFolder.parent != null) && (
+              <button
+                id='create-bookmark-folder-button'
+                type="button"
+                className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
+                onClick={onClickPlusButton}
+              >
+                <i className="icon-plus d-block p-0" />
+              </button>
+            )}
           </div>
         </li>
       </DragAndDropWrapper>

+ 17 - 6
apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx

@@ -12,7 +12,7 @@ import { toastError } from '~/client/util/toastr';
 import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 import { useSWRBookmarkInfo, useSWRxCurrentUserBookmarks } from '~/stores/bookmark';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useSWRxCurrentPage, useSWRxPageInfo } from '~/stores/page';
 
 import { FolderIcon } from '../Icons/FolderIcon';
 
@@ -33,12 +33,12 @@ export const BookmarkFolderMenu = (props: Props): JSX.Element => {
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: userBookmarkInfo, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(currentPage?._id);
   const { mutate: mutateUserBookmarks } = useSWRxCurrentUserBookmarks();
+  const { mutate: mutatePageInfo } = useSWRxPageInfo(currentPage?._id);
   const isBookmarked = userBookmarkInfo?.isBookmarked ?? false;
   const [isOpen, setIsOpen] = useState(false);
 
 
   const toggleBookmarkHandler = useCallback(async() => {
-
     try {
       if (currentPage != null) {
         await toggleBookmark(currentPage._id, isBookmarked);
@@ -47,8 +47,6 @@ export const BookmarkFolderMenu = (props: Props): JSX.Element => {
     catch (err) {
       toastError(err);
     }
-
-
   }, [currentPage, isBookmarked]);
 
   const onClickNewBookmarkFolder = useCallback(() => {
@@ -60,8 +58,9 @@ export const BookmarkFolderMenu = (props: Props): JSX.Element => {
     mutateUserBookmarks();
     mutateBookmarkInfo();
     mutateBookmarkFolderData();
+    mutatePageInfo();
     setSelectedItem(null);
-  }, [mutateBookmarkFolderData, mutateBookmarkInfo, mutateUserBookmarks, toggleBookmarkHandler]);
+  }, [mutateBookmarkFolderData, mutateBookmarkInfo, mutatePageInfo, mutateUserBookmarks, toggleBookmarkHandler]);
 
   const toggleHandler = useCallback(async() => {
     setIsOpen(!isOpen);
@@ -80,13 +79,25 @@ export const BookmarkFolderMenu = (props: Props): JSX.Element => {
         toggleBookmarkHandler();
         mutateUserBookmarks();
         mutateBookmarkInfo();
+        mutatePageInfo();
         setSelectedItem(null);
       }
       catch (err) {
         toastError(err);
       }
     }
-  }, [isOpen, mutateBookmarkFolderData, bookmarkFolders, isBookmarked, currentPage?._id, toggleBookmarkHandler, mutateUserBookmarks, mutateBookmarkInfo]);
+  },
+  [
+    isOpen,
+    mutateBookmarkFolderData,
+    bookmarkFolders,
+    isBookmarked,
+    currentPage?._id,
+    toggleBookmarkHandler,
+    mutateUserBookmarks,
+    mutateBookmarkInfo,
+    mutatePageInfo,
+  ]);
 
 
   const isBookmarkFolderExists = useCallback((): boolean => {

+ 14 - 21
apps/app/src/components/Bookmarks/BookmarkFolderMenuItem.tsx

@@ -5,7 +5,7 @@ import React, {
 import { useTranslation } from 'next-i18next';
 import {
   DropdownItem,
-  DropdownMenu, DropdownToggle, UncontrolledDropdown, UncontrolledTooltip,
+  DropdownMenu, DropdownToggle, UncontrolledDropdown,
 } from 'reactstrap';
 
 import {
@@ -201,29 +201,22 @@ export const BookmarkFolderMenuItem = (props: Props): JSX.Element => {
         >
           <i className="icon-fw icon-trash grw-page-control-dropdown-icon"></i>
         </DropdownToggle>
-        <DropdownToggle
-          color="transparent"
-          onClick={e => e.stopPropagation()}
-          onMouseEnter={onMouseEnterHandler}
-        >
-          {childrenExists
-            ? <TriangleIcon />
-            : (
-              <i className="icon-plus d-block pl-0" />
-            )}
-        </DropdownToggle>
+        {/* Maximum folder hierarchy of 2 levels */}
+        {item.parent == null && (
+          <DropdownToggle
+            color="transparent"
+            onClick={e => e.stopPropagation()}
+            onMouseEnter={onMouseEnterHandler}
+          >
+            {childrenExists
+              ? <TriangleIcon />
+              : <i className="icon-plus d-block pl-0" />
+            }
+          </DropdownToggle>
+        )}
         {renderBookmarkSubMenuItem()}
 
       </UncontrolledDropdown >
-      <UncontrolledTooltip
-        modifiers={{ preventOverflow: { boundariesElement: 'window' } }}
-        autohide={false}
-        placement="top"
-        target={`bookmark-delete-button-${item._id}`}
-        fade={false}
-      >
-        {t('bookmark_folder.delete')}
-      </UncontrolledTooltip>
     </>
   );
 };

+ 5 - 1
apps/app/src/components/Fab.tsx

@@ -68,7 +68,11 @@ export const Fab = (): JSX.Element => {
 
   const PageCreateButton = useCallback(() => {
     return (
-      <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: '2.3rem', right: '4rem' }}>
+      <div
+        className={`rounded-circle position-absolute ${animateClasses}`}
+        style={{ bottom: '2.3rem', right: '4rem' }}
+        data-testid="grw-fab-page-create-button"
+      >
         <button
           type="button"
           className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 ${buttonClasses}`}

+ 20 - 3
apps/app/src/components/InAppNotification/InAppNotificationElm.tsx

@@ -8,11 +8,12 @@ import { DropdownItem } from 'reactstrap';
 
 import { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
 import { apiv3Post } from '~/client/util/apiv3-client';
+import { SupportedTargetModel } from '~/interfaces/activity';
 import { IInAppNotification, InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 
 // Change the display for each targetmodel
 import PageModelNotification from './PageNotification/PageModelNotification';
-
+import UserModelNotification from './PageNotification/UserModelNotification';
 
 interface Props {
   notification: IInAppNotification & HasObjectId
@@ -40,6 +41,10 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
   };
 
   const getActionUsers = () => {
+    if (notification.targetModel === SupportedTargetModel.MODEL_USER) {
+      return notification.target.username;
+    }
+
     const latestActionUsers = notification.actionUsers.slice(0, 3);
     const latestUsers = latestActionUsers.map((user) => {
       return `@${user.name}`;
@@ -75,7 +80,6 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
         <div className="position-absolute" style={{ top: 10, left: 10 }}>
           <UserPicture user={actionUsers[1]} size="md" noTooltip />
         </div>
-
       </div>
     );
   };
@@ -139,6 +143,10 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
       actionMsg = 'commented on';
       actionIcon = 'icon-bubble';
       break;
+    case 'USER_REGISTRATION_APPROVAL_REQUEST':
+      actionMsg = 'requested registration approval';
+      actionIcon = 'icon-bubble';
+      break;
     default:
       actionMsg = '';
       actionIcon = '';
@@ -163,7 +171,7 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
         >
         </span>
         {renderActionUserPictures()}
-        {notification.targetModel === 'Page' && (
+        {notification.targetModel === SupportedTargetModel.MODEL_PAGE && (
           <PageModelNotification
             ref={notificationRef}
             notification={notification}
@@ -172,6 +180,15 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
             actionUsers={actionUsers}
           />
         )}
+        {notification.targetModel === SupportedTargetModel.MODEL_USER && (
+          <UserModelNotification
+            ref={notificationRef}
+            notification={notification}
+            actionMsg={actionMsg}
+            actionIcon={actionIcon}
+            actionUsers={actionUsers}
+          />
+        )}
       </div>
     </TagElem>
   );

+ 46 - 0
apps/app/src/components/InAppNotification/PageNotification/UserModelNotification.tsx

@@ -0,0 +1,46 @@
+import React, {
+  forwardRef, ForwardRefRenderFunction, useImperativeHandle,
+} from 'react';
+
+import { HasObjectId } from '@growi/core';
+import { useRouter } from 'next/router';
+
+import type { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
+import type { IInAppNotification } from '~/interfaces/in-app-notification';
+
+import FormattedDistanceDate from '../../FormattedDistanceDate';
+
+const UserModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable, {
+  notification: IInAppNotification & HasObjectId
+  actionMsg: string
+  actionIcon: string
+  actionUsers: string
+}> = ({
+  notification, actionMsg, actionIcon, actionUsers,
+}, ref) => {
+  const router = useRouter();
+
+  // publish open()
+  useImperativeHandle(ref, () => ({
+    open() {
+      router.push('/admin/users');
+    },
+  }));
+
+  return (
+    <div className="p-2 overflow-hidden">
+      <div className="text-truncate">
+        <b>{actionUsers}</b> {actionMsg}
+      </div>
+      <i className={`${actionIcon} mr-2`} />
+      <FormattedDistanceDate
+        id={notification._id}
+        date={notification.createdAt}
+        isShowTooltip={false}
+        differenceForAvoidingFormat={Number.POSITIVE_INFINITY}
+      />
+    </div>
+  );
+};
+
+export default forwardRef(UserModelNotification);

+ 1 - 1
apps/app/src/components/Navbar/GrowiSubNavigationSwitcher.tsx

@@ -83,7 +83,7 @@ export const GrowiSubNavigationSwitcher = (props: GrowiSubNavigationSwitcherProp
   }
 
   return (
-    <div className={`${styles['grw-subnav-switcher']} ${isSticky ? '' : 'grw-subnav-switcher-hidden'}`}>
+    <div className={`${styles['grw-subnav-switcher']} ${isSticky ? '' : 'grw-subnav-switcher-hidden'}`} data-testid="grw-subnav-switcher" >
       <div
         id="grw-subnav-fixed-container"
         className={`grw-subnav-fixed-container ${styles['grw-subnav-fixed-container']} position-fixed grw-subnav-append-shadow-container`}

+ 33 - 14
apps/app/src/components/PasswordResetRequestForm.tsx

@@ -5,10 +5,11 @@ import Link from 'next/link';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-
+import { useIsMailerSetup } from '~/stores/context';
 
 const PasswordResetRequestForm: FC = () => {
   const { t } = useTranslation();
+  const { data: isMailerSetup } = useIsMailerSetup();
   const [email, setEmail] = useState('');
 
   const changeEmail = useCallback((inputValue) => {
@@ -33,20 +34,38 @@ const PasswordResetRequestForm: FC = () => {
 
   return (
     <form onSubmit={sendPasswordResetRequestMail}>
-      <h3>{ t('forgot_password.password_reset_request_desc') }</h3>
-      <div className="form-group">
-        <div className="input-group">
-          <input name="email" placeholder="E-mail Address" className="form-control" type="email" onChange={e => changeEmail(e.target.value)} />
+      {!isMailerSetup ? (
+        <div className="alert alert-danger">
+          {t('forgot_password.please_enable_mailer_alert')}
         </div>
-      </div>
-      <div className="form-group">
-        <button
-          className="btn btn-lg btn-primary btn-block"
-          type="submit"
-        >
-          {t('forgot_password.send')}
-        </button>
-      </div>
+      ) : (
+        <>
+          <h1><i className="icon-lock large"></i></h1>
+          <h1 className="text-center">{ t('forgot_password.forgot_password') }</h1>
+          <h3>{t('forgot_password.password_reset_request_desc')}</h3>
+          <div className="form-group">
+            <div className="input-group">
+              <input
+                name="email"
+                placeholder="E-mail Address"
+                className="form-control"
+                type="email"
+                disabled={!isMailerSetup}
+                onChange={e => changeEmail(e.target.value)}
+              />
+            </div>
+          </div>
+          <div className="form-group">
+            <button
+              className="btn btn-lg btn-primary btn-block"
+              type="submit"
+              disabled={!isMailerSetup}
+            >
+              {t('forgot_password.send')}
+            </button>
+          </div>
+        </>
+      )}
       <Link href='/login' prefetch={false}>
         <i className="icon-login mr-1" />{t('forgot_password.return_to_login')}
       </Link>

+ 6 - 0
apps/app/src/interfaces/activity.ts

@@ -4,10 +4,12 @@ import { IUser } from './user';
 
 // Model
 const MODEL_PAGE = 'Page';
+const MODEL_USER = 'User';
 const MODEL_COMMENT = 'Comment';
 
 // Action
 const ACTION_UNSETTLED = 'UNSETTLED';
+const ACTION_USER_REGISTRATION_APPROVAL_REQUEST = 'USER_REGISTRATION_APPROVAL_REQUEST';
 const ACTION_USER_REGISTRATION_SUCCESS = 'USER_REGISTRATION_SUCCESS';
 const ACTION_USER_LOGIN_WITH_LOCAL = 'USER_LOGIN_WITH_LOCAL';
 const ACTION_USER_LOGIN_WITH_LDAP = 'USER_LOGIN_WITH_LDAP';
@@ -162,6 +164,7 @@ const ACTION_ADMIN_SEARCH_INDICES_REBUILD = 'ADMIN_SEARCH_INDICES_REBUILD';
 
 export const SupportedTargetModel = {
   MODEL_PAGE,
+  MODEL_USER,
 } as const;
 
 export const SupportedEventModel = {
@@ -182,6 +185,7 @@ export const SupportedActionCategory = {
 
 export const SupportedAction = {
   ACTION_UNSETTLED,
+  ACTION_USER_REGISTRATION_APPROVAL_REQUEST,
   ACTION_USER_REGISTRATION_SUCCESS,
   ACTION_USER_LOGIN_WITH_LOCAL,
   ACTION_USER_LOGIN_WITH_LDAP,
@@ -349,6 +353,7 @@ export const EssentialActionGroup = {
   ACTION_PAGE_RECURSIVELY_DELETE_COMPLETELY,
   ACTION_PAGE_RECURSIVELY_REVERT,
   ACTION_COMMENT_CREATE,
+  ACTION_USER_REGISTRATION_APPROVAL_REQUEST,
 } as const;
 
 export const ActionGroupSize = {
@@ -375,6 +380,7 @@ export const SmallActionGroup = {
 // SmallActionGroup + Action by all General Users - PAGE_VIEW
 export const MediumActionGroup = {
   ...SmallActionGroup,
+  ACTION_USER_REGISTRATION_APPROVAL_REQUEST,
   ACTION_USER_REGISTRATION_SUCCESS,
   ACTION_USER_FOGOT_PASSWORD,
   ACTION_USER_RESET_PASSWORD,

+ 8 - 4
apps/app/src/interfaces/in-app-notification.ts

@@ -1,5 +1,7 @@
 import type { IPageSnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
+import type { IUserSnapshot } from '~/models/serializers/in-app-notification-snapshot/user';
 
+import { SupportedTargetModelType, SupportedActionType } from './activity';
 import { IPage } from './page';
 import { IUser } from './user';
 
@@ -9,16 +11,18 @@ export enum InAppNotificationStatuses {
   STATUS_OPENED = 'OPENED',
 }
 
+// TODO: do not use any type
+// https://redmine.weseek.co.jp/issues/120632
 export interface IInAppNotification {
   user: IUser
-  targetModel: 'Page'
-  target: IPage
-  action: 'COMMENT' | 'LIKE'
+  targetModel: SupportedTargetModelType
+  target: any
+  action: SupportedActionType
   status: InAppNotificationStatuses
   actionUsers: IUser[]
   createdAt: Date
   snapshot: string
-  parsedSnapshot?: IPageSnapshot
+  parsedSnapshot?: any
 }
 
 /*

+ 15 - 0
apps/app/src/models/serializers/in-app-notification-snapshot/user.ts

@@ -0,0 +1,15 @@
+import type { IUser } from '~/interfaces/user';
+
+export interface IUserSnapshot {
+  username: string
+}
+
+export const stringifySnapshot = (user: IUser): string => {
+  return JSON.stringify({
+    username: user.username,
+  });
+};
+
+export const parseSnapshot = (snapshot: string): IUserSnapshot => {
+  return JSON.parse(snapshot);
+};

+ 20 - 7
apps/app/src/pages/forgot-password.page.tsx

@@ -1,18 +1,24 @@
 import React from 'react';
 
 import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
-import { useTranslation } from 'next-i18next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 
+import { CrowiRequest } from '~/interfaces/crowi-request';
+import { useIsMailerSetup } from '~/stores/context';
+
 import {
   CommonProps, getNextI18NextConfig, getServerSideCommonProps,
 } from './utils/commons';
 
 const PasswordResetRequestForm = dynamic(() => import('~/components/PasswordResetRequestForm'), { ssr: false });
 
-const ForgotPasswordPage: NextPage = () => {
-  const { t } = useTranslation();
+type Props = CommonProps & {
+  isMailerSetup: boolean,
+};
+
+const ForgotPasswordPage: NextPage<Props> = (props: Props) => {
+  useIsMailerSetup(props.isMailerSetup);
 
   return (
     <div id="main" className="main">
@@ -21,8 +27,6 @@ const ForgotPasswordPage: NextPage = () => {
           <div className="row justify-content-md-center">
             <div className="col-md-6 mt-5">
               <div className="text-center">
-                <h1><i className="icon-lock large"></i></h1>
-                <h1 className="text-center">{ t('forgot_password.forgot_password') }</h1>
                 <PasswordResetRequestForm />
               </div>
             </div>
@@ -34,11 +38,19 @@ const ForgotPasswordPage: NextPage = () => {
 };
 
 // eslint-disable-next-line max-len
-async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: CommonProps, namespacesRequired?: string[] | undefined): Promise<void> {
+async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
   const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
   props._nextI18Next = nextI18NextConfig._nextI18Next;
 }
 
+const injectServerConfigurations = async(context: GetServerSidePropsContext, props: Props): Promise<void> => {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+  const { mailService } = crowi;
+
+  props.isMailerSetup = mailService.isMailerSetup;
+};
+
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
   const result = await getServerSideCommonProps(context);
 
@@ -48,8 +60,9 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
     throw new Error('invalid getSSP result');
   }
 
-  const props: CommonProps = result.props as CommonProps;
+  const props: Props = result.props as Props;
 
+  injectServerConfigurations(context, props);
   await injectNextI18NextConfigurations(context, props, ['translation', 'commons']);
 
   return {

+ 18 - 3
apps/app/src/server/models/bookmark-folder.ts

@@ -3,7 +3,6 @@ import monggoose, {
   Types, Document, Model, Schema,
 } from 'mongoose';
 
-
 import { IBookmarkFolder, BookmarkFolderItems, MyBookmarkList } from '~/interfaces/bookmark-info';
 import { IPageHasId } from '~/interfaces/page';
 
@@ -16,7 +15,6 @@ import { InvalidParentBookmarkFolderError } from './errors';
 const logger = loggerFactory('growi:models:bookmark-folder');
 const Bookmark = monggoose.model('Bookmark');
 
-
 export interface BookmarkFolderDocument extends Document {
   _id: Types.ObjectId
   name: string
@@ -39,7 +37,11 @@ export interface BookmarkFolderModel extends Model<BookmarkFolderDocument>{
 const bookmarkFolderSchema = new Schema<BookmarkFolderDocument, BookmarkFolderModel>({
   name: { type: String },
   owner: { type: Schema.Types.ObjectId, ref: 'User', index: true },
-  parent: { type: Schema.Types.ObjectId, ref: 'BookmarkFolder', required: false },
+  parent: {
+    type: Schema.Types.ObjectId,
+    ref: 'BookmarkFolder',
+    required: false,
+  },
   bookmarks: {
     type: [{
       type: Schema.Types.ObjectId, ref: 'Bookmark', required: false,
@@ -154,6 +156,19 @@ bookmarkFolderSchema.statics.updateBookmarkFolder = async function(bookmarkFolde
   const parentFolder = parentId ? await this.findById(parentId) : null;
   updateFields.parent = parentFolder?._id ?? null;
 
+  // Maximum folder hierarchy of 2 levels
+  // If the destination folder (parentFolder) has a parent, the source folder cannot be moved because the destination folder hierarchy is already 2.
+  // If the drop source folder has child folders, the drop source folder cannot be moved because the drop source folder hierarchy is already 2.
+  if (parentId != null) {
+    if (parentFolder?.parent != null) {
+      throw new Error('Update bookmark folder failed');
+    }
+    const bookmarkFolder = await this.findById(bookmarkFolderId).populate('children');
+    if (bookmarkFolder?.children?.length !== 0) {
+      throw new Error('Update bookmark folder failed');
+    }
+  }
+
   const bookmarkFolder = await this.findByIdAndUpdate(bookmarkFolderId, { $set: updateFields }, { new: true });
   if (bookmarkFolder == null) {
     throw new Error('Update bookmark folder failed');

+ 7 - 2
apps/app/src/server/routes/apiv3/bookmark-folder.ts

@@ -1,7 +1,6 @@
 import { ErrorV3 } from '@growi/core';
 import { body } from 'express-validator';
 
-import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { InvalidParentBookmarkFolderError } from '~/server/models/errors';
 import loggerFactory from '~/utils/logger';
@@ -17,7 +16,13 @@ const router = express.Router();
 const validator = {
   bookmarkFolder: [
     body('name').isString().withMessage('name must be a string'),
-    body('parent').isMongoId().optional({ nullable: true }),
+    body('parent').isMongoId().optional({ nullable: true })
+      .custom(async(parent: string) => {
+        const parentFolder = await BookmarkFolder.findById(parent);
+        if (parentFolder == null || parentFolder.parent != null) {
+          throw new Error('Maximum folder hierarchy of 2 levels');
+        }
+      }),
   ],
   bookmarkPage: [
     body('pageId').isMongoId().withMessage('Page ID must be a valid mongo ID'),

+ 2 - 2
apps/app/src/server/routes/attachment.js

@@ -245,7 +245,7 @@ module.exports = function(crowi, app) {
       'Last-Modified': attachment.createdAt.toUTCString(),
     });
 
-    if (!attachment.fileSize) {
+    if (attachment.fileSize) {
       res.set({
         'Content-Length': attachment.fileSize,
       });
@@ -261,7 +261,7 @@ module.exports = function(crowi, app) {
     else {
       res.set({
         'Content-Type': attachment.fileFormat,
-        'Content-Security-Policy': "script-src 'unsafe-hashes'; object-src 'none'; require-trusted-types-for 'script'; default-src 'none';",
+        'Content-Security-Policy': "script-src 'unsafe-hashes'; object-src 'none'; require-trusted-types-for 'script'; media-src 'self'; default-src 'none';",
       });
     }
   }

+ 19 - 8
apps/app/src/server/routes/login.js

@@ -1,4 +1,4 @@
-import { SupportedAction } from '~/interfaces/activity';
+import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
 // disable all of linting
@@ -10,7 +10,7 @@ module.exports = function(crowi, app) {
   const path = require('path');
   const User = crowi.model('User');
   const {
-    configManager, appService, aclService, mailService,
+    configManager, appService, aclService, mailService, activityService,
   } = crowi;
   const activityEvent = crowi.event('activity');
 
@@ -42,12 +42,28 @@ module.exports = function(crowi, app) {
       .forEach(result => logger.error(result.reason));
   }
 
+  async function sendNotificationToAllAdmins(user) {
+    const adminUsers = await User.findAdmins();
+    const activity = await activityService.createActivity({
+      action: SupportedAction.ACTION_USER_REGISTRATION_APPROVAL_REQUEST,
+      target: user,
+      targetModel: SupportedTargetModel.MODEL_USER,
+    });
+    await activityEvent.emit('updated', activity, user, adminUsers);
+    return;
+  }
+
   const registerSuccessHandler = async function(req, res, userData, registrationMode) {
     const parameters = { action: SupportedAction.ACTION_USER_REGISTRATION_SUCCESS };
     activityEvent.emit('update', res.locals.activity._id, parameters);
 
+    const isMailerSetup = mailService.isMailerSetup ?? false;
+
     if (registrationMode === aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
-      await sendEmailToAllAdmins(userData);
+      sendNotificationToAllAdmins(userData);
+      if (isMailerSetup) {
+        await sendEmailToAllAdmins(userData);
+      }
       return res.apiv3({});
     }
 
@@ -142,11 +158,6 @@ module.exports = function(crowi, app) {
       }
 
       const registrationMode = configManager.getConfig('crowi', 'security:registrationMode');
-      const isMailerSetup = mailService.isMailerSetup ?? false;
-
-      if (!isMailerSetup && registrationMode === aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
-        return res.apiv3Err(['message.email_settings_is_not_setup'], 403);
-      }
 
       User.createUserByEmailAndPassword(name, username, email, password, undefined, async(err, userData) => {
         if (err) {

+ 3 - 1
apps/app/src/server/service/file-uploader/gcs.js

@@ -201,7 +201,9 @@ module.exports = function(crowi) {
 
     const gcs = getGcsInstance();
     const bucket = gcs.bucket(getGcsBucket());
-    const [files] = await bucket.getFiles();
+    const [files] = await bucket.getFiles({
+      prefix: configManager.getConfig('crowi', 'gcs:uploadNamespace'),
+    });
 
     return files.map(({ name, metadata: { size } }) => {
       return { name, size };

+ 19 - 8
apps/app/src/server/service/in-app-notification.ts

@@ -6,7 +6,8 @@ import { Types } from 'mongoose';
 
 import { AllEssentialActions, SupportedAction } from '~/interfaces/activity';
 import { InAppNotificationStatuses, PaginateResult } from '~/interfaces/in-app-notification';
-import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
+import * as pageSerializers from '~/models/serializers/in-app-notification-snapshot/page';
+import * as userSerializers from '~/models/serializers/in-app-notification-snapshot/user';
 import { ActivityDocument } from '~/server/models/activity';
 import {
   InAppNotification,
@@ -17,7 +18,6 @@ import Subscription from '~/server/models/subscription';
 import loggerFactory from '~/utils/logger';
 
 import Crowi from '../crowi';
-import { PageDocument } from '../models/page';
 import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
 
 
@@ -51,11 +51,13 @@ export default class InAppNotificationService {
   }
 
   initActivityEventListeners(): void {
-    this.activityEvent.on('updated', async(activity: ActivityDocument, target: IPage, descendantsSubscribedUsers?: Ref<IUser>[]) => {
+    // TODO: do not use any type
+    // https://redmine.weseek.co.jp/issues/120632
+    this.activityEvent.on('updated', async(activity: ActivityDocument, target: any, users?: Ref<IUser>[]) => {
       try {
         const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
         if (shouldNotification) {
-          await this.createInAppNotification(activity, target, descendantsSubscribedUsers);
+          await this.createInAppNotification(activity, target, users);
         }
       }
       catch (err) {
@@ -199,9 +201,18 @@ export default class InAppNotificationService {
     return;
   };
 
-  createInAppNotification = async function(activity: ActivityDocument, target: IPage, descendantsSubscribedUsers?: Ref<IUser>[]): Promise<void> {
+  // TODO: do not use any type
+  // https://redmine.weseek.co.jp/issues/120632
+  createInAppNotification = async function(activity: ActivityDocument, target, users?: Ref<IUser>[]): Promise<void> {
+    if (activity.action === SupportedAction.ACTION_USER_REGISTRATION_APPROVAL_REQUEST) {
+      const snapshot = userSerializers.stringifySnapshot(target);
+      await this.upsertByActivity(users, activity, snapshot);
+      await this.emitSocketIo(users);
+      return;
+    }
+
     const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
-    const snapshot = stringifySnapshot(target);
+    const snapshot = pageSerializers.stringifySnapshot(target);
     if (shouldNotification) {
       let mentionedUsers: IUser[] = [];
       if (activity.action === SupportedAction.ACTION_COMMENT_CREATE) {
@@ -209,9 +220,9 @@ export default class InAppNotificationService {
       }
       const notificationTargetUsers = await activity?.getNotificationTargetUsers();
       let notificationDescendantsUsers = [];
-      if (descendantsSubscribedUsers != null) {
+      if (users != null) {
         const User = this.crowi.model('User');
-        const descendantsUsers = descendantsSubscribedUsers.filter(item => (item.toString() !== activity.user._id.toString()));
+        const descendantsUsers = users.filter(item => (item.toString() !== activity.user._id.toString()));
         notificationDescendantsUsers = await User.find({
           _id: { $in: descendantsUsers },
           status: User.STATUS_ACTIVE,

+ 5 - 6
apps/app/src/server/service/page.ts

@@ -1501,8 +1501,7 @@ class PageService {
         throw err;
       }
     }
-    this.pageEvent.emit('delete', page, user);
-    this.pageEvent.emit('create', deletedPage, user);
+    this.pageEvent.emit('delete', page, deletedPage, user);
 
     return deletedPage;
   }
@@ -1558,8 +1557,7 @@ class PageService {
       }
     }
 
-    this.pageEvent.emit('delete', page, user);
-    this.pageEvent.emit('create', deletedPage, user);
+    this.pageEvent.emit('delete', page, deletedPage, user);
 
     return deletedPage;
   }
@@ -2063,7 +2061,7 @@ class PageService {
 
     await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: false } });
 
-    this.pageEvent.emit('revert', page, user);
+    this.pageEvent.emit('revert', page, updatedPage, user);
 
     if (!isRecursively) {
       await this.updateDescendantCountOfAncestors(parent._id, 1, true);
@@ -2092,6 +2090,7 @@ class PageService {
       (async() => {
         try {
           await this.revertRecursivelyMainOperation(page, user, options, pageOp._id, activity);
+          this.pageEvent.emit('syncDescendantsUpdate', updatedPage, user);
         }
         catch (err) {
           logger.error('Error occurred while running revertRecursivelyMainOperation.', err);
@@ -2180,7 +2179,7 @@ class PageService {
     }, { new: true });
     await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: false } });
 
-    this.pageEvent.emit('revert', page, user);
+    this.pageEvent.emit('revert', page, updatedPage, user);
 
     return updatedPage;
   }

+ 8 - 2
apps/app/src/server/service/search.ts

@@ -140,8 +140,14 @@ class SearchService implements SearchQueryParser, SearchResolver {
     const pageEvent = this.crowi.event('page');
     pageEvent.on('create', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
     pageEvent.on('update', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
-    pageEvent.on('delete', this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator));
-    pageEvent.on('revert', this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator));
+    pageEvent.on('delete', (targetPage, deletedPage, user) => {
+      this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator)(targetPage, user);
+      this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator)(deletedPage, user);
+    });
+    pageEvent.on('revert', (targetPage, revertedPage, user) => {
+      this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator)(targetPage, user);
+      this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator)(revertedPage, user);
+    });
     pageEvent.on('deleteCompletely', this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator));
     pageEvent.on('syncDescendantsDelete', this.fullTextSearchDelegator.syncDescendantsPagesDeleted.bind(this.fullTextSearchDelegator));
     pageEvent.on('updateMany', this.fullTextSearchDelegator.syncPagesUpdated.bind(this.fullTextSearchDelegator));

+ 2 - 1
apps/app/src/services/renderer/renderer.tsx

@@ -35,9 +35,10 @@ const logger = loggerFactory('growi:services:renderer');
 type SanitizePlugin = PluginTuple<[SanitizeOption]>;
 
 const baseSanitizeSchema = {
-  tagNames: ['iframe', 'section'],
+  tagNames: ['iframe', 'section', 'video'],
   attributes: {
     iframe: ['allow', 'referrerpolicy', 'sandbox', 'src', 'srcdoc'],
+    video: ['controls', 'src', 'muted', 'preload', 'width', 'height', 'autoplay'],
     // The special value 'data*' as a property name can be used to allow all data properties.
     // see: https://github.com/syntax-tree/hast-util-sanitize/
     '*': ['key', 'class', 'className', 'style', 'data*'],

+ 13 - 2
apps/app/src/stores/in-app-notification.ts

@@ -1,7 +1,9 @@
 import useSWR, { SWRConfiguration, SWRResponse } from 'swr';
 
+import { SupportedTargetModel } from '~/interfaces/activity';
 import type { InAppNotificationStatuses, IInAppNotification, PaginateResult } from '~/interfaces/in-app-notification';
-import { parseSnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
+import * as pageSerializers from '~/models/serializers/in-app-notification-snapshot/page';
+import * as userSerializers from '~/models/serializers/in-app-notification-snapshot/user';
 import loggerFactory from '~/utils/logger';
 
 import { apiv3Get } from '../client/util/apiv3-client';
@@ -23,7 +25,16 @@ export const useSWRxInAppNotifications = <Data, Error>(
       const inAppNotificationPaginateResult = response.data as inAppNotificationPaginateResult;
       inAppNotificationPaginateResult.docs.forEach((doc) => {
         try {
-          doc.parsedSnapshot = parseSnapshot(doc.snapshot as string);
+          switch (doc.targetModel) {
+            case SupportedTargetModel.MODEL_PAGE:
+              doc.parsedSnapshot = pageSerializers.parseSnapshot(doc.snapshot);
+              break;
+            case SupportedTargetModel.MODEL_USER:
+              doc.parsedSnapshot = userSerializers.parseSnapshot(doc.snapshot);
+              break;
+            default:
+              throw new Error(`No serializer found for targetModel: ${doc.targetModel}`);
+          }
         }
         catch (err) {
           logger.warn('Failed to parse snapshot', err);

+ 137 - 0
apps/app/test/cypress/integration/20-basic-features/20-basic-features--sticky-features.spec.ts

@@ -0,0 +1,137 @@
+context('Access to any page', () => {
+  const ssPrefix = 'subnav-and-fab-';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+
+    cy.visit('/');
+
+    cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true, true);
+  });
+
+  it('Subnavigation and fab displays changes on scroll down and up', () => {
+    cy.waitUntil(() => {
+      // do
+      // Scroll the window 250px down is enough to trigger sticky effect
+       cy.scrollTo(0, 250);
+      // wait until
+      return cy.getByTestid('grw-subnav-switcher').then($elem => !$elem.hasClass('grw-subnav-switcher-hidden'));
+    });
+    // wait until fab visible
+    cy.waitUntil(() => cy.getByTestid('grw-fab-page-create-button').then($elem => $elem.hasClass('visible')));
+
+    cy.waitUntilSkeletonDisappear();
+    cy.screenshot(`${ssPrefix}visible-on-scroll-down`);
+
+    cy.waitUntil(() => {
+      // do
+      // Scroll the window back to top
+      cy.scrollTo(0, 0);
+      // wait until
+      return cy.waitUntil(() => cy.getByTestid('grw-subnav-switcher').then($elem => $elem.hasClass('grw-subnav-switcher-hidden')));
+    });
+    // wait until fab invisible
+    cy.waitUntil(() => cy.getByTestid('grw-fab-page-create-button').then($elem => $elem.hasClass('invisible')));
+
+    cy.screenshot(`${ssPrefix}invisible-on-scroll-top`);
+  });
+
+  it('Subnavigation and fab are not displayed when move to other pages', () => {
+    cy.waitUntil(() => {
+      // do
+      // Scroll the window 250px down is enough to trigger sticky effect
+      cy.scrollTo(0, 250);
+      // wait until
+      return () => cy.getByTestid('grw-subnav-switcher').then($elem => !$elem.hasClass('grw-subnav-switcher-hidden'));
+    });
+    cy.waitUntil(() => cy.getByTestid('grw-fab-page-create-button').then($elem => $elem.hasClass('visible')));
+
+    // Move to /Sandbox page
+    cy.visit('/Sandbox');
+
+    cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true);
+
+    cy.waitUntil(() => cy.getByTestid('grw-fab-page-create-button').then($elem => $elem.hasClass('invisible')));
+    cy.waitUntil(() => cy.getByTestid('grw-subnav-switcher').then($elem => $elem.hasClass('grw-subnav-switcher-hidden')));
+    cy.screenshot(`${ssPrefix}not-visible-on-move-to-other-pages`);
+  });
+
+  it('Able to open create page modal from fab', () => {
+    cy.waitUntil(() => {
+      // do
+      // Scroll the window back to top
+      cy.scrollTo(0, 250);
+      // wait until
+      return cy.getByTestid('grw-fab-page-create-button')
+      .should('have.class', 'visible')
+      .within(() => {
+        cy.get('.btn-create-page').click();
+        return true;
+      });
+    });
+
+    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
+      cy.screenshot(`${ssPrefix}new-page-modal-opened-from-fab`);
+      cy.get('button.close').click();
+    });
+  });
+
+  it('Able to scroll page to top from fab', () => {
+    // Initial scroll down
+    cy.waitUntil(() => {
+      // do
+      // Scroll the window 250px down is enough to trigger sticky effect
+      cy.scrollTo(0, 250);
+      // wait until
+      return cy.getByTestid('grw-fab-return-to-top')
+        .should('have.class', 'visible')
+        .then(() => {
+          cy.waitUntil(() => {
+            cy.get('.btn-scroll-to-top').click();
+            return cy.getByTestid('grw-fab-return-to-top').should('have.class', 'invisible');
+          });
+        });
+    });
+    cy.waitUntilSkeletonDisappear();
+    cy.screenshot(`${ssPrefix}scroll-page-to-top`);
+  });
+
+  it('Able to click buttons on subnavigation switcher when sticky', () => {
+    cy.waitUntil(() => {
+      // do
+      // Scroll the window 250px down is enough to trigger sticky effect
+      cy.scrollTo(0, 250);
+      // wait until
+      return cy.getByTestid('grw-subnav-switcher').then($elem => !$elem.hasClass('grw-subnav-switcher-hidden'));
+    });
+    cy.waitUntil(() => {
+      cy.getByTestid('grw-subnav-switcher').within(() => {
+        cy.getByTestid('editor-button').should('be.visible').click();
+      });
+      return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
+    });
+    cy.get('.grw-editor-navbar-bottom').should('be.visible');
+    cy.screenshot(`${ssPrefix}open-editor-when-sticky`);
+  });
+
+  it('Subnavigation is sticky when on small window', () => {
+    cy.waitUntil(() => {
+      // do
+      // Scroll the window 500px down
+      cy.scrollTo(0, 500);
+      // wait until
+      return cy.getByTestid('grw-subnav-switcher').then($elem => !$elem.hasClass('grw-subnav-switcher-hidden'));
+    });
+    cy.waitUntilSkeletonDisappear();
+    cy.viewport(600, 1024);
+    cy.getByTestid('grw-subnav-switcher').within(() => {
+      cy.get('#grw-page-editor-mode-manager').should('be.visible');
+    })
+    cy.screenshot(`${ssPrefix}sticky-on-small-window`);
+  });
+});

+ 58 - 0
apps/app/test/cypress/integration/21-basic-features-for-guest/21-basic-features-for-guest--sticky-for-guest.spec.ts

@@ -0,0 +1,58 @@
+context('Access sticky sub navigation switcher and Fab for guest', () => {
+  const ssPrefix = 'access-sticky-by-guest-';
+  it('Sub navigation sticky changes when scrolling down and up', () => {
+    cy.visit('/Sandbox');
+    cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true, true);
+
+    // Sticky
+    cy.waitUntil(() => {
+      // do
+      // Scroll page down 250px
+      cy.scrollTo(0, 250);
+      // wait until
+      return cy.getByTestid('grw-subnav-switcher').then($elem => !$elem.hasClass('grw-subnav-switcher-hidden'));
+    });
+    cy.screenshot(`${ssPrefix}subnav-switcher-is-sticky-on-scroll-down`);
+
+    // Not sticky
+    cy.waitUntil(() => {
+      // do
+      // Scroll page to top
+      cy.scrollTo(0, 0);
+      // wait until
+      return cy.getByTestid('grw-subnav-switcher').then($elem => $elem.hasClass('grw-subnav-switcher-hidden'));
+    });
+    cy.screenshot(`${ssPrefix}subnav-switcher-is-not-sticky-on-scroll-top`);
+  });
+
+  it('Fab display changes when scrolling down and up', () => {
+    cy.visit('/Sandbox');
+    cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true, true);
+
+    // Visible
+    cy.waitUntil(() => {
+      // do
+      // Scroll the window 250px down is enough to trigger sticky effect
+       cy.scrollTo(0, 250);
+
+      // wait until
+      return cy.getByTestid('grw-fab-return-to-top').then($elem => $elem.hasClass('visible'));
+
+    });
+    cy.screenshot(`${ssPrefix}fab-is-visible-on-scroll-down`);
+
+    // Invisible
+    cy.waitUntil(() => {
+      // do
+      // Scroll page to top
+       cy.scrollTo(0, 0);
+
+       // wait until
+      return cy.getByTestid('grw-fab-return-to-top').then($elem => $elem.hasClass('invisible'));
+    });
+    cy.screenshot(`${ssPrefix}fab-is-invisible-on-scroll-top`);
+
+  });
+});

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

@@ -666,8 +666,7 @@ describe('PageService', () => {
       expect(resultPage.updatedAt).toEqual(parentForDelete1.updatedAt);
       expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
 
-      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete1, testUser2);
-      expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2);
+      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete1, resultPage, testUser2);
     });
 
     test('delete page with isRecursively', async() => {
@@ -686,8 +685,7 @@ describe('PageService', () => {
       expect(resultPage.updatedAt).toEqual(parentForDelete2.updatedAt);
       expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
 
-      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete2, testUser2);
-      expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2);
+      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete2, resultPage, testUser2);
     });
 
 

+ 42 - 44
apps/slackbot-proxy/docker/Dockerfile

@@ -1,38 +1,40 @@
 # syntax = docker/dockerfile:1.4
 
 ##
-## packages-json-picker
+## base
 ##
-FROM node:16-slim AS packages-json-picker
+FROM node:18-slim AS base
 
 ENV optDir /opt
 
 WORKDIR ${optDir}
-COPY ["package.json", "yarn.lock", "lerna.json", "./"]
-COPY packages packages
-# Find and remove non-package.json files
-RUN find packages \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf
+
+RUN yarn global add turbo
+COPY . .
+RUN turbo prune --scope=@growi/slackbot-proxy --docker
 
 
 ##
-## deps-resolver-dev
+## deps-resolver
 ##
-FROM node:16-slim AS deps-resolver-dev
+FROM node:18-slim AS deps-resolver
 
 ENV optDir /opt
 
 WORKDIR ${optDir}
 
 # copy files
-COPY --from=packages-json-picker ${optDir} .
+COPY --from=base ${optDir}/out/json/ .
+COPY --from=base ${optDir}/out/yarn.lock ./yarn.lock
 
-# setup
-RUN yarn config set network-timeout 300000
-RUN npx -y lerna bootstrap -- --frozen-lockfile
+# setup (with network-timeout = 1 hour)
+RUN yarn config set network-timeout 3600000
+RUN yarn --frozen-lockfile
 
 # make artifacts
 RUN tar -cf node_modules.tar \
   node_modules \
+  apps/*/node_modules \
   packages/*/node_modules
 
 
@@ -40,19 +42,13 @@ RUN tar -cf node_modules.tar \
 ##
 ## deps-resolver-prod
 ##
-FROM node:16-slim AS deps-resolver-prod
-
-ENV optDir /opt
-
-WORKDIR ${optDir}
-COPY ["package.json", "yarn.lock", "lerna.json", "./"]
-COPY ./packages/slack/package.json ./packages/slack/package.json
-COPY ./apps/slackbot-proxy/package.json ./apps/slackbot-proxy/package.json
+FROM deps-resolver AS deps-resolver-prod
 
-RUN npx -y lerna bootstrap -- --production
+RUN yarn --production
 # make artifacts
-RUN tar -cf dependencies.tar \
+RUN tar -cf node_modules.tar \
   node_modules \
+  apps/*/node_modules \
   packages/*/node_modules
 
 
@@ -60,62 +56,64 @@ RUN tar -cf dependencies.tar \
 ##
 ## builder
 ##
-FROM node:16-slim AS builder
+FROM node:18-slim AS builder
 
 ENV optDir /opt
 
 WORKDIR ${optDir}
 
+RUN yarn global add turbo
+
+# copy files
+COPY --from=base ${optDir}/out/full/ .
+COPY --from=base ${optDir}/out/yarn.lock ./yarn.lock
+COPY ["tsconfig.base.json", "./"]
+
 # copy dependent packages
-COPY --from=deps-resolver-dev \
+COPY --from=deps-resolver \
   ${optDir}/node_modules.tar ${optDir}/
 
 # extract node_modules.tar
 RUN tar -xf node_modules.tar
 RUN rm node_modules.tar
 
-COPY ["package.json", "lerna.json", "tsconfig.base.json", "./"]
-# copy all related packages
-COPY packages/slack packages/slack
-COPY apps/slackbot-proxy apps/slackbot-proxy
-
 # build
-RUN yarn lerna run build
+RUN turbo run build
 
 # make artifacts
 RUN tar -cf packages.tar \
-  packages/slack/package.json \
-  packages/slack/dist \
-  apps/slackbot-proxy/package.json \
-  apps/slackbot-proxy/.env \
-  apps/slackbot-proxy/dist
+  package.json \
+  apps/*/package.json \
+  apps/*/dist \
+  apps/*/.env \
+  packages/*/package.json \
+  packages/*/dist
 
 
 
 ##
 ## release
 ##
-FROM node:16-slim
+FROM node:18-slim
 LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 ENV NODE_ENV production
 
 ENV optDir /opt
-ENV appDir ${optDir}
-
+ENV appDir ${optDir}/slackbot-proxy
 USER node
-
-WORKDIR ${appDir}
 # copy artifacts
 COPY --from=deps-resolver-prod --chown=node:node \
-  ${optDir}/dependencies.tar ./
+  ${optDir}/node_modules.tar ${appDir}/
 COPY --from=builder --chown=node:node \
-  ${optDir}/packages.tar ./
+  ${optDir}/packages.tar ${appDir}/
+
+WORKDIR ${appDir}
 
 # extract artifacts
-RUN tar -xf dependencies.tar
+RUN tar -xf node_modules.tar
 RUN tar -xf packages.tar
-RUN rm dependencies.tar packages.tar
+RUN rm node_modules.tar packages.tar
 
 WORKDIR ${appDir}/apps/slackbot-proxy
 

+ 3 - 0
apps/slackbot-proxy/docker/Dockerfile.dockerignore

@@ -3,3 +3,6 @@
 **/coverage
 **/Dockerfile
 **/*.dockerignore
+**/.turbo
+out
+apps/app

+ 1 - 1
packages/core/src/models/devided-page-path.js

@@ -32,7 +32,7 @@ export class DevidedPagePath {
       }
     }
 
-    let PATTERN_DEFAULT = /^((.*)\/)?(.+)$/; // this will not ignore html end tags https://regex101.com/r/jpZwIe/1
+    let PATTERN_DEFAULT = /^((.*)\/(?!em>))?(.+)$/; // this will ignore em's end tags
     try { // for non-chrome browsers
       // eslint-disable-next-line regex/invalid
       PATTERN_DEFAULT = new RegExp('^((.*)(?<!<)\\/)?(.+)$'); // https://regex101.com/r/HJNvMW/1

+ 2 - 1
turbo.json

@@ -76,7 +76,8 @@
     "@growi/app#dev:styles-prebuilt": {
       "outputs": ["src/styles/prebuilt/**"],
       "inputs": [
-        "src/styles/**/*.scss"
+        "src/styles/**/*.scss",
+        "!src/styles/prebuilt/**"
       ],
       "outputMode": "new-only"
     },