فهرست منبع

Merge pull request #7879 from weseek/master

Release v6.1.7
Yuki Takei 2 سال پیش
والد
کامیت
3dad662fe0
84فایلهای تغییر یافته به همراه961 افزوده شده و 766 حذف شده
  1. 5 12
      .github/workflows/reusable-app-prod.yml
  2. 1 0
      apps/app/cypress.config.ts
  3. 1 1
      apps/app/package.json
  4. 0 34
      apps/app/src/client/services/ShowPageAccessoriesModal.tsx
  5. 0 166
      apps/app/src/components/Admin/Common/AdminNavigation.jsx
  6. 167 0
      apps/app/src/components/Admin/Common/AdminNavigation.tsx
  7. 3 2
      apps/app/src/components/Admin/Notification/ManageGlobalNotification.tsx
  8. 1 1
      apps/app/src/components/Admin/Security/ShareLinkSetting.tsx
  9. 3 2
      apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  10. 2 2
      apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  11. 6 2
      apps/app/src/components/Admin/UserGroupDetail/UserGroupPageList.tsx
  12. 3 2
      apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx
  13. 3 3
      apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx
  14. 10 6
      apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx
  15. 42 16
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  16. 1 6
      apps/app/src/components/DescendantsPageList.tsx
  17. 6 16
      apps/app/src/components/InstallerForm.tsx
  18. 6 13
      apps/app/src/components/InvitedForm.tsx
  19. 1 1
      apps/app/src/components/Layout/AdminLayout.tsx
  20. 4 2
      apps/app/src/components/Layout/BasicLayout.tsx
  21. 46 9
      apps/app/src/components/LoginForm.tsx
  22. 2 2
      apps/app/src/components/Me/DisassociateModal.tsx
  23. 19 17
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  24. 6 3
      apps/app/src/components/Navbar/SubNavButtons.tsx
  25. 1 1
      apps/app/src/components/Page/PageView.tsx
  26. 0 0
      apps/app/src/components/PageAccessoriesModal/PageAccessoriesModal.module.scss
  27. 25 57
      apps/app/src/components/PageAccessoriesModal/PageAccessoriesModal.tsx
  28. 2 2
      apps/app/src/components/PageAccessoriesModal/PageAttachment.tsx
  29. 8 12
      apps/app/src/components/PageAccessoriesModal/PageHistory.tsx
  30. 1 3
      apps/app/src/components/PageAccessoriesModal/ShareLink/ShareLink.tsx
  31. 0 0
      apps/app/src/components/PageAccessoriesModal/ShareLink/ShareLinkForm.tsx
  32. 1 1
      apps/app/src/components/PageAccessoriesModal/ShareLink/ShareLinkList.tsx
  33. 1 0
      apps/app/src/components/PageAccessoriesModal/ShareLink/index.ts
  34. 79 0
      apps/app/src/components/PageAccessoriesModal/hooks.tsx
  35. 1 0
      apps/app/src/components/PageAccessoriesModal/index.ts
  36. 4 4
      apps/app/src/components/PageAlert/TrashPageAlert.tsx
  37. 21 21
      apps/app/src/components/PageList/PageListItemL.tsx
  38. 6 3
      apps/app/src/components/PageList/PageListItemS.tsx
  39. 1 1
      apps/app/src/components/ReactMarkdownComponents/Header.tsx
  40. 20 7
      apps/app/src/components/ReactMarkdownComponents/NextLink.tsx
  41. 1 2
      apps/app/src/components/SearchPage/SearchPageBase.tsx
  42. 0 1
      apps/app/src/components/SearchPage/SearchResultList.tsx
  43. 7 7
      apps/app/src/components/ShareLinkPageView.tsx
  44. 1 1
      apps/app/src/components/Sidebar.tsx
  45. 3 7
      apps/app/src/features/questionnaire/interfaces/growi-info.ts
  46. 0 11
      apps/app/src/interfaces/external-account.ts
  47. 1 13
      apps/app/src/pages/[[...path]].page.tsx
  48. 0 8
      apps/app/src/pages/_search.page.tsx
  49. 1 1
      apps/app/src/pages/share/[[...path]].page.tsx
  50. 0 2
      apps/app/src/pages/trash.page.tsx
  51. 2 1
      apps/app/src/server/routes/apiv3/index.js
  52. 65 0
      apps/app/src/server/routes/apiv3/security-settings/checkSetupStrategiesHasAdmin.ts
  53. 158 148
      apps/app/src/server/routes/apiv3/security-settings/index.js
  54. 13 4
      apps/app/src/stores/modal.tsx
  55. 3 4
      apps/app/src/stores/personal-settings.tsx
  56. 51 33
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-page.cy.ts
  57. 2 4
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--use-tools.cy.ts
  58. 10 6
      apps/app/test/cypress/e2e/21-basic-features-for-guest/21-basic-features-for-guest--access-to-page.cy.ts
  59. 3 5
      apps/app/test/cypress/e2e/22-sharelink/22-sharelink--access-to-sharelink.cy.ts
  60. 6 5
      apps/app/test/cypress/e2e/23-editor/23-editor--saving.cy.ts
  61. 31 8
      apps/app/test/cypress/e2e/23-editor/23-editor--with-navigation.cy.ts
  62. 1 7
      apps/app/test/cypress/e2e/50-sidebar/50-sidebar--access-to-side-bar.cy.ts
  63. 0 13
      apps/app/test/cypress/e2e/50-sidebar/50-sidebar--switching-sidebar-mode.cy.ts
  64. 21 0
      apps/app/test/cypress/support/assertions.ts
  65. 22 19
      apps/app/test/cypress/support/commands.ts
  66. 1 0
      apps/app/test/cypress/support/index.ts
  67. 1 1
      apps/slackbot-proxy/package.json
  68. 1 1
      package.json
  69. 1 1
      packages/core/package.json
  70. 21 0
      packages/core/src/interfaces/external-account.ts
  71. 1 0
      packages/core/src/interfaces/index.ts
  72. 2 1
      packages/core/src/interfaces/user.ts
  73. 1 1
      packages/core/src/utils/page-path-utils/index.ts
  74. 1 1
      packages/hackmd/package.json
  75. 1 1
      packages/presentation/package.json
  76. 1 1
      packages/preset-templates/package.json
  77. 1 1
      packages/preset-themes/package.json
  78. 1 1
      packages/remark-attachment-refs/package.json
  79. 1 1
      packages/remark-drawio/package.json
  80. 1 1
      packages/remark-growi-directive/package.json
  81. 1 1
      packages/remark-lsx/package.json
  82. 1 1
      packages/slack/package.json
  83. 1 1
      packages/ui/package.json
  84. 11 11
      yarn.lock

+ 5 - 12
.github/workflows/reusable-app-prod.yml

@@ -59,15 +59,17 @@ jobs:
         yarn --frozen-lockfile
 
     - name: Restore dist
-      uses: actions/cache/restore@v3
+      uses: actions/cache@v3
       with:
         path: |
+          node_modules/.cache/turbo
           **/.turbo
           **/dist
           ${{ github.workspace }}/apps/app/.next
-        key: dist-app-prod-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('node_modules/.cache/turbo/*-meta.json') }}
+        key: dist-app-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ github.ref_name }}-${{ github.sha }}
         restore-keys: |
-          dist-app-prod-${{ runner.OS }}-node${{ matrix.node-version }}-
+          dist-app-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ github.ref_name }}-
+          dist-app-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
 
     - name: Build
       working-directory: ./apps/app
@@ -117,15 +119,6 @@ jobs:
         isCompactMode: true
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
-    - name: Cache dist
-      uses: actions/cache/save@v3
-      with:
-        path: |
-          **/.turbo
-          **/dist
-          ${{ github.workspace }}/apps/app/.next
-        key: dist-app-prod-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('node_modules/.cache/turbo/*-meta.json') }}
-
 
   launch-prod:
     needs: [build-prod]

+ 1 - 0
apps/app/cypress.config.ts

@@ -22,6 +22,7 @@ export default defineConfig({
   fixturesFolder: 'test/cypress/fixtures',
   screenshotsFolder: 'test/cypress/screenshots',
   videosFolder: 'test/cypress/videos',
+  video: false,
 
   viewportWidth: 1400,
   viewportHeight: 1024,

+ 1 - 1
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "6.1.6",
+  "version": "6.1.7-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",

+ 0 - 34
apps/app/src/client/services/ShowPageAccessoriesModal.tsx

@@ -1,34 +0,0 @@
-import React, { useEffect, useState } from 'react';
-
-import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/modal';
-
-function getURLQueryParamValue(key: string) {
-// window.location.href is page URL;
-  const queryStr: URLSearchParams = new URL(window.location.href).searchParams;
-  return queryStr.get(key);
-}
-
-const queryCompareFormat = new RegExp(/([a-z0-9]){24}...([a-z0-9]){24}/);
-
-const ShowPageAccessoriesModal = (): JSX.Element => {
-  const { data: status, open: openPageAccessories } = usePageAccessoriesModal();
-  const [isArleadyMounted, setIsArleadyMounted] = useState(false);
-  useEffect(() => {
-    const pageIdParams = getURLQueryParamValue('compare');
-    if (status == null || status.isOpened === true) {
-      return;
-    }
-    if (isArleadyMounted === true) {
-      return;
-    }
-    if (pageIdParams != null) {
-      if (queryCompareFormat.test(pageIdParams)) {
-        openPageAccessories(PageAccessoriesModalContents.PageHistory);
-      }
-    }
-    setIsArleadyMounted(true);
-  }, [openPageAccessories, status, isArleadyMounted]);
-  return <></>;
-};
-
-export default ShowPageAccessoriesModal;

+ 0 - 166
apps/app/src/components/Admin/Common/AdminNavigation.jsx

@@ -1,166 +0,0 @@
-import React from 'react';
-
-import { pathUtils } from '@growi/core';
-import { useTranslation } from 'next-i18next';
-import Link from 'next/link';
-import PropTypes from 'prop-types';
-import urljoin from 'url-join';
-
-import { useGrowiCloudUri, useGrowiAppIdForGrowiCloud } from '../../../stores/context';
-// import AppContainer from '~/client/services/AppContainer';
-
-// import { withUnstatedContainers } from '../../UnstatedUtils';
-
-const AdminNavigation = (props) => {
-  const { t } = useTranslation(['admin', 'commons']);
-  // const { appContainer } = props;
-  const pathname = window.location.pathname;
-
-  const { data: growiCloudUri } = useGrowiCloudUri();
-  const { data: growiAppIdForGrowiCloud } = useGrowiAppIdForGrowiCloud();
-
-  // eslint-disable-next-line react/prop-types
-  const MenuLabel = ({ menu }) => {
-    switch (menu) {
-      /* eslint-disable no-multi-spaces, max-len */
-      case 'app':                      return <><i className="mr-1 icon-fw icon-settings"></i>{        t('headers.app_settings', { ns: 'commons' }) }</>;
-      case 'security':                 return <><i className="mr-1 icon-fw icon-shield"></i>{          t('security_settings.security_settings') }</>;
-      case 'markdown':                 return <><i className="mr-1 icon-fw icon-note"></i>{            t('markdown_settings.markdown_settings') }</>;
-      case 'customize':                return <><i className="mr-1 icon-fw icon-wrench"></i>{          t('customize_settings.customize_settings') }</>;
-      case 'importer':                 return <><i className="mr-1 icon-fw icon-cloud-upload"></i>{    t('importer_management.import_data') }</>;
-      case 'export':                   return <><i className="mr-1 icon-fw icon-cloud-download"></i>{  t('export_management.export_archive_data') }</>;
-      case 'data-transfer':            return <><i className="mr-1 icon-fw icon-plane"></i>{           t('g2g_data_transfer.data_transfer', { ns: 'commons' })}</>;
-      case 'notification':             return <><i className="mr-1 icon-fw icon-bell"></i>{            t('external_notification.external_notification')}</>;
-      case 'slack-integration':        return <><i className="mr-1 icon-fw icon-shuffle"></i>{         t('slack_integration.slack_integration') }</>;
-      case 'slack-integration-legacy': return <><i className="mr-1 icon-fw icon-shuffle"></i>{         t('slack_integration_legacy.slack_integration_legacy')}</>;
-      case 'users':                    return <><i className="mr-1 icon-fw icon-user"></i>{            t('user_management.user_management') }</>;
-      case 'user-groups':              return <><i className="mr-1 icon-fw icon-people"></i>{          t('user_group_management.user_group_management') }</>;
-      case 'audit-log':                return <><i className="mr-1 icon-fw icon-feed"></i>{            t('audit_log_management.audit_log')}</>;
-      case 'plugins':                  return <><i className="mr-1 icon-fw icon-puzzle"></i>{          t('plugins.plugins')}</>;
-      case 'search':                   return <><i className="mr-1 icon-fw icon-magnifier"></i>{       t('full_text_search_management.full_text_search_management') }</>;
-      case 'cloud':                    return <><i className="mr-1 icon-fw icon-share-alt"></i>{       t('cloud_setting_management.to_cloud_settings')} </>;
-      default:                         return <><i className="mr-1 icon-fw icon-home"></i>{            t('wiki_management_home_page') }</>;
-      /* eslint-enable no-multi-spaces, max-len */
-    }
-  };
-
-  const MenuLink = ({
-    // eslint-disable-next-line react/prop-types
-    menu, isRoot, isListGroupItems, isActive,
-  }) => {
-    const pageTransitionClassName = isListGroupItems
-      ? 'list-group-item list-group-item-action border-0 round-corner'
-      : 'dropdown-item px-3 py-2';
-
-    const href = isRoot ? '/admin' : urljoin('/admin', menu);
-
-    return (
-      <Link
-        href={href}
-        className={`${pageTransitionClassName} ${isActive ? 'active' : ''}`}
-      >
-        <MenuLabel menu={menu} />
-      </Link>
-    );
-  };
-
-  const isActiveMenu = (path) => {
-    const basisPath = pathUtils.normalizePath(urljoin('/admin', path));
-    const basisParentPath = pathUtils.addTrailingSlash(basisPath);
-
-    return (
-      pathname === basisPath
-      || pathname.startsWith(basisParentPath)
-    );
-  };
-
-  const getListGroupItemOrDropdownItemList = (isListGroupItems) => {
-    return (
-      <>
-        {/* eslint-disable no-multi-spaces */}
-        <MenuLink menu="home"         isListGroupItems isActive={pathname === '/admin'} isRoot />
-        <MenuLink menu="app"          isListGroupItems isActive={isActiveMenu('/app')} />
-        <MenuLink menu="security"     isListGroupItems isActive={isActiveMenu('/security')} />
-        <MenuLink menu="markdown"     isListGroupItems isActive={isActiveMenu('/markdown')} />
-        <MenuLink menu="customize"    isListGroupItems isActive={isActiveMenu('/customize')} />
-        <MenuLink menu="importer"     isListGroupItems isActive={isActiveMenu('/importer')} />
-        <MenuLink menu="export"       isListGroupItems isActive={isActiveMenu('/export')} />
-        <MenuLink menu="data-transfer" isListGroupItems isActive={isActiveMenu('/data-transfer')} />
-        <MenuLink menu="notification" isListGroupItems isActive={isActiveMenu('/notification') || isActiveMenu('/global-notification')} />
-        <MenuLink menu="slack-integration" isListGroupItems isActive={isActiveMenu('/slack-integration')} />
-        <MenuLink menu="slack-integration-legacy" isListGroupItems isActive={isActiveMenu('/slack-integration-legacy')} />
-        <MenuLink menu="users"        isListGroupItems isActive={isActiveMenu('/users')} />
-        <MenuLink menu="user-groups"  isListGroupItems isActive={isActiveMenu('/user-groups')} />
-        <MenuLink menu="audit-log"    isListGroupItems isActive={isActiveMenu('/audit-log')} />
-        <MenuLink menu="plugins"      isListGroupItems isActive={isActiveMenu('/plugins')} />
-        <MenuLink menu="search"       isListGroupItems isActive={isActiveMenu('/search')} />
-        {growiCloudUri != null && growiAppIdForGrowiCloud != null
-          && (
-            <a
-              href={`${growiCloudUri}/my/apps/${growiAppIdForGrowiCloud}`}
-              className="list-group-item list-group-item-action border-0 round-corner"
-            >
-              <MenuLabel menu="cloud" />
-            </a>
-          )
-        }
-        {/* eslint-enable no-multi-spaces */}
-      </>
-    );
-  };
-
-  return (
-    <React.Fragment>
-      {/* List group */}
-      <div className="list-group admin-navigation sticky-top d-none d-lg-block">
-        {getListGroupItemOrDropdownItemList(true)}
-      </div>
-
-      {/* Dropdown */}
-      <div className="dropdown d-block d-lg-none mb-5">
-        <button
-          className="btn btn-outline-primary btn-lg dropdown-toggle col-12 text-right"
-          type="button"
-          id="dropdown-admin-navigation"
-          data-display="static"
-          data-toggle="dropdown"
-          aria-haspopup="true"
-          aria-expanded="false"
-        >
-          <span className="float-left">
-            {/* eslint-disable no-multi-spaces */}
-            {pathname === '/admin' &&              <MenuLabel menu="home" />}
-            {isActiveMenu('/app') &&               <MenuLabel menu="app" />}
-            {isActiveMenu('/security') &&          <MenuLabel menu="security" />}
-            {isActiveMenu('/markdown') &&          <MenuLabel menu="markdown" />}
-            {isActiveMenu('/customize') &&         <MenuLabel menu="customize" />}
-            {isActiveMenu('/importer') &&          <MenuLabel menu="importer" />}
-            {isActiveMenu('/export') &&            <MenuLabel menu="export" />}
-            {(isActiveMenu('/notification') || isActiveMenu('/global-notification')) && <MenuLabel menu="notification" />}
-            {isActiveMenu('/slack-integration') && <MenuLabel menu="slack-integration" />}
-            {isActiveMenu('/users') &&             <MenuLabel menu="users" />}
-            {isActiveMenu('/user-groups') &&       <MenuLabel menu="user-groups" />}
-            {isActiveMenu('/search') &&            <MenuLabel menu="search" />}
-            {isActiveMenu('/audit-log') &&         <MenuLabel menu="audit-log" />}
-            {isActiveMenu('/plugins') &&           <MenuLabel menu="plugins" />}
-            {isActiveMenu('/data-transfer') &&     <MenuLabel menu="data-transfer" />}
-            {/* eslint-enable no-multi-spaces */}
-          </span>
-        </button>
-        <div className="dropdown-menu" aria-labelledby="dropdown-admin-navigation">
-          {getListGroupItemOrDropdownItemList(false)}
-        </div>
-      </div>
-
-    </React.Fragment>
-  );
-};
-
-// const AdminNavigationWrapper = withUnstatedContainers(AdminNavigation, [AppContainer]);
-
-AdminNavigation.propTypes = {
-  // appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-// export default AdminNavigationWrapper;
-export default AdminNavigation;

+ 167 - 0
apps/app/src/components/Admin/Common/AdminNavigation.tsx

@@ -0,0 +1,167 @@
+import React, { useCallback } from 'react';
+
+import { pathUtils } from '@growi/core';
+import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
+import urljoin from 'url-join';
+
+import { useGrowiCloudUri, useGrowiAppIdForGrowiCloud } from '../../../stores/context';
+
+// eslint-disable-next-line react/prop-types
+const MenuLabel = ({ menu }: { menu: string }) => {
+  const { t } = useTranslation(['admin', 'commons']);
+
+  switch (menu) {
+    /* eslint-disable no-multi-spaces, max-len */
+    case 'app':                      return <><i className="mr-1 icon-fw icon-settings"></i>{        t('headers.app_settings', { ns: 'commons' }) }</>;
+    case 'security':                 return <><i className="mr-1 icon-fw icon-shield"></i>{          t('security_settings.security_settings') }</>;
+    case 'markdown':                 return <><i className="mr-1 icon-fw icon-note"></i>{            t('markdown_settings.markdown_settings') }</>;
+    case 'customize':                return <><i className="mr-1 icon-fw icon-wrench"></i>{          t('customize_settings.customize_settings') }</>;
+    case 'importer':                 return <><i className="mr-1 icon-fw icon-cloud-upload"></i>{    t('importer_management.import_data') }</>;
+    case 'export':                   return <><i className="mr-1 icon-fw icon-cloud-download"></i>{  t('export_management.export_archive_data') }</>;
+    case 'data-transfer':            return <><i className="mr-1 icon-fw icon-plane"></i>{           t('g2g_data_transfer.data_transfer', { ns: 'commons' })}</>;
+    case 'notification':             return <><i className="mr-1 icon-fw icon-bell"></i>{            t('external_notification.external_notification')}</>;
+    case 'slack-integration':        return <><i className="mr-1 icon-fw icon-shuffle"></i>{         t('slack_integration.slack_integration') }</>;
+    case 'slack-integration-legacy': return <><i className="mr-1 icon-fw icon-shuffle"></i>{         t('slack_integration_legacy.slack_integration_legacy')}</>;
+    case 'users':                    return <><i className="mr-1 icon-fw icon-user"></i>{            t('user_management.user_management') }</>;
+    case 'user-groups':              return <><i className="mr-1 icon-fw icon-people"></i>{          t('user_group_management.user_group_management') }</>;
+    case 'audit-log':                return <><i className="mr-1 icon-fw icon-feed"></i>{            t('audit_log_management.audit_log')}</>;
+    case 'plugins':                  return <><i className="mr-1 icon-fw icon-puzzle"></i>{          t('plugins.plugins')}</>;
+    case 'search':                   return <><i className="mr-1 icon-fw icon-magnifier"></i>{       t('full_text_search_management.full_text_search_management') }</>;
+    case 'cloud':                    return <><i className="mr-1 icon-fw icon-share-alt"></i>{       t('cloud_setting_management.to_cloud_settings')} </>;
+    default:                         return <><i className="mr-1 icon-fw icon-home"></i>{            t('wiki_management_home_page') }</>;
+      /* eslint-enable no-multi-spaces, max-len */
+  }
+};
+
+type MenuLinkProps = {
+  menu: string,
+  isListGroupItems: boolean,
+  isRoot?: boolean,
+  isActive?: boolean,
+}
+
+const MenuLink = ({
+  menu, isRoot, isListGroupItems, isActive,
+}: MenuLinkProps) => {
+
+  const pageTransitionClassName = isListGroupItems
+    ? 'list-group-item list-group-item-action border-0 round-corner'
+    : 'dropdown-item px-3 py-2';
+
+  const href = isRoot ? '/admin' : urljoin('/admin', menu);
+
+  return (
+    <Link
+      href={href}
+      className={`${pageTransitionClassName} ${isActive ? 'active' : ''}`}
+    >
+      <MenuLabel menu={menu} />
+    </Link>
+  );
+};
+
+export const AdminNavigation = (): JSX.Element => {
+  const pathname = window.location.pathname;
+
+  const { data: growiCloudUri } = useGrowiCloudUri();
+  const { data: growiAppIdForGrowiCloud } = useGrowiAppIdForGrowiCloud();
+
+  const isActiveMenu = useCallback((path: string | string[]) => {
+    const paths = Array.isArray(path) ? path : [path];
+
+    return paths.some((path) => {
+      const basisPath = pathUtils.normalizePath(urljoin('/admin', path));
+      const basisParentPath = pathUtils.addTrailingSlash(basisPath);
+
+      return (
+        pathname === basisPath
+        || pathname.startsWith(basisParentPath)
+      );
+    });
+
+  }, [pathname]);
+
+  const getListGroupItemOrDropdownItemList = (isListGroupItems: boolean) => {
+    return (
+      <>
+        {/* eslint-disable no-multi-spaces */}
+        <MenuLink menu="home"                       isListGroupItems={isListGroupItems} isActive={pathname === '/admin'} isRoot />
+        <MenuLink menu="app"                        isListGroupItems={isListGroupItems} isActive={isActiveMenu('/app')} />
+        <MenuLink menu="security"                   isListGroupItems={isListGroupItems} isActive={isActiveMenu('/security')} />
+        <MenuLink menu="markdown"                   isListGroupItems={isListGroupItems} isActive={isActiveMenu('/markdown')} />
+        <MenuLink menu="customize"                  isListGroupItems={isListGroupItems} isActive={isActiveMenu('/customize')} />
+        <MenuLink menu="importer"                   isListGroupItems={isListGroupItems} isActive={isActiveMenu('/importer')} />
+        <MenuLink menu="export"                     isListGroupItems={isListGroupItems} isActive={isActiveMenu('/export')} />
+        <MenuLink menu="data-transfer"              isListGroupItems={isListGroupItems} isActive={isActiveMenu('/data-transfer')} />
+        <MenuLink menu="notification"               isListGroupItems={isListGroupItems} isActive={isActiveMenu(['/notification', '/global-notification'])} />
+        <MenuLink menu="slack-integration"          isListGroupItems={isListGroupItems} isActive={isActiveMenu('/slack-integration')} />
+        <MenuLink menu="slack-integration-legacy"   isListGroupItems={isListGroupItems} isActive={isActiveMenu('/slack-integration-legacy')} />
+        <MenuLink menu="users"                      isListGroupItems={isListGroupItems} isActive={isActiveMenu('/users')} />
+        <MenuLink menu="user-groups"                isListGroupItems={isListGroupItems} isActive={isActiveMenu(['/user-groups', 'user-group-detail'])} />
+        <MenuLink menu="audit-log"                  isListGroupItems={isListGroupItems} isActive={isActiveMenu('/audit-log')} />
+        <MenuLink menu="plugins"                    isListGroupItems={isListGroupItems} isActive={isActiveMenu('/plugins')} />
+        <MenuLink menu="search"                     isListGroupItems={isListGroupItems} isActive={isActiveMenu('/search')} />
+        {growiCloudUri != null && growiAppIdForGrowiCloud != null
+          && (
+            <a
+              href={`${growiCloudUri}/my/apps/${growiAppIdForGrowiCloud}`}
+              className="list-group-item list-group-item-action border-0 round-corner"
+            >
+              <MenuLabel menu="cloud" />
+            </a>
+          )
+        }
+        {/* eslint-enable no-multi-spaces */}
+      </>
+    );
+  };
+
+  return (
+    <React.Fragment>
+      {/* List group */}
+      <div className="list-group admin-navigation sticky-top d-none d-lg-block">
+        {getListGroupItemOrDropdownItemList(true)}
+      </div>
+
+      {/* Dropdown */}
+      <div className="dropdown d-block d-lg-none mb-5">
+        <button
+          className="btn btn-outline-primary btn-lg dropdown-toggle col-12 text-right"
+          type="button"
+          id="dropdown-admin-navigation"
+          data-display="static"
+          data-toggle="dropdown"
+          aria-haspopup="true"
+          aria-expanded="false"
+        >
+          <span className="float-left">
+            {/* eslint-disable no-multi-spaces */}
+            {pathname === '/admin'                  && <MenuLabel menu="home" />}
+            {isActiveMenu('/app')                   && <MenuLabel menu="app" />}
+            {isActiveMenu('/security')              && <MenuLabel menu="security" />}
+            {isActiveMenu('/markdown')              && <MenuLabel menu="markdown" />}
+            {isActiveMenu('/customize')             && <MenuLabel menu="customize" />}
+            {isActiveMenu('/importer')              && <MenuLabel menu="importer" />}
+            {isActiveMenu('/export')                && <MenuLabel menu="export" />}
+            {(isActiveMenu(['/notification', '/global-notification']))
+                                                    && <MenuLabel menu="notification" />}
+            {isActiveMenu('/slack-integration')     && <MenuLabel menu="slack-integration" />}
+            {isActiveMenu('/users')                 && <MenuLabel menu="users" />}
+            {isActiveMenu(['/user-groups', 'user-group-detail'])
+                                                    && <MenuLabel menu="user-groups" />}
+            {isActiveMenu('/search')                && <MenuLabel menu="search" />}
+            {isActiveMenu('/audit-log')             && <MenuLabel menu="audit-log" />}
+            {isActiveMenu('/plugins')               && <MenuLabel menu="plugins" />}
+            {isActiveMenu('/data-transfer')         && <MenuLabel menu="data-transfer" />}
+            {/* eslint-enable no-multi-spaces */}
+          </span>
+        </button>
+        <div className="dropdown-menu" aria-labelledby="dropdown-admin-navigation">
+          {getListGroupItemOrDropdownItemList(false)}
+        </div>
+      </div>
+
+    </React.Fragment>
+  );
+};

+ 3 - 2
apps/app/src/components/Admin/Notification/ManageGlobalNotification.tsx

@@ -3,6 +3,7 @@ import React, {
 } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 import { useRouter } from 'next/router';
 
 import { NotifyType, TriggerEventType } from '~/client/interfaces/global-notification';
@@ -111,10 +112,10 @@ const ManageGlobalNotification = (props: Props): JSX.Element => {
   return (
     <>
       <div className="my-3">
-        <a href="/admin/notification#global-notification" className="btn btn-outline-secondary">
+        <Link href="/admin/notification" className="btn btn-outline-secondary">
           <i className="icon-fw ti ti-arrow-left" aria-hidden="true"></i>
           {t('notification_settings.back_to_list')}
-        </a>
+        </Link>
       </div>
 
 

+ 1 - 1
apps/app/src/components/Admin/Security/ShareLinkSetting.tsx

@@ -8,8 +8,8 @@ import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurit
 import { apiv3Delete } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 
+import ShareLinkList from '../../PageAccessoriesModal/ShareLink/ShareLinkList';
 import PaginationWrapper from '../../PaginationWrapper';
-import ShareLinkList from '../../ShareLink/ShareLinkList';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import DeleteAllShareLinksModal from './DeleteAllShareLinksModal';

+ 3 - 2
apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -5,6 +5,7 @@ import React, {
 import type { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '@growi/core';
 import dateFnsFormat from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 
 
 type Props = {
@@ -147,7 +148,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
               <tr key={group._id}>
                 {props.isAclEnabled
                   ? (
-                    <td><a href={`/admin/user-group-detail/${group._id}`}>{group.name}</a></td>
+                    <td><Link href={`/admin/user-group-detail/${group._id}`}>{group.name}</Link></td>
                   )
                   : (
                     <td>{group.name}</td>
@@ -168,7 +169,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
                         <li key={group._id} className="list-inline-item badge badge-success">
                           {props.isAclEnabled
                             ? (
-                              <a href={`/admin/user-group-detail/${group._id}`}>{group.name}</a>
+                              <Link href={`/admin/user-group-detail/${group._id}`}>{group.name}</Link>
                             )
                             : (
                               <p>{group.name}</p>

+ 2 - 2
apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -334,7 +334,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
       <nav aria-label="breadcrumb">
         <ol className="breadcrumb">
           <li className="breadcrumb-item">
-            <Link href="/admin/user-groups" prefetch={false}>
+            <Link href="/admin/user-groups">
               {t('user_group_management.group_list')}
             </Link>
           </li>
@@ -348,7 +348,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
                 { ancestorUserGroup._id === currentUserGroupId ? (
                   <span>{ancestorUserGroup.name}</span>
                 ) : (
-                  <Link href={`/admin/user-group-detail/${ancestorUserGroup._id}`} prefetch={false}>
+                  <Link href={`/admin/user-group-detail/${ancestorUserGroup._id}`}>
                     {ancestorUserGroup.name}
                   </Link>
                 ) }

+ 6 - 2
apps/app/src/components/Admin/UserGroupDetail/UserGroupPageList.tsx

@@ -49,8 +49,12 @@ const UserGroupPageList = (props: Props): JSX.Element => {
 
   return (
     <>
-      <ul className="page-list-ul page-list-ul-flat mb-3">
-        {currentPages.map(page => <li key={page._id}><PageListItemS page={page} /></li>)}
+      <ul className="page-list-ul page-list-ul-flat mt-3 mb-3">
+        { currentPages.map(page => (
+          <li key={page._id} className="mt-2">
+            <PageListItemS page={page} />
+          </li>
+        )) }
       </ul>
       {relatedPages != null && relatedPages.length === 0 ? <p>{t('user_group_management.no_pages')}</p> : (
         <PaginationWrapper

+ 3 - 2
apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx

@@ -121,7 +121,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     }
   };
 
-  const isDropable = (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;
@@ -143,6 +143,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     return true;
   };
 
+
   const renderChildFolder = () => {
     return isOpen && children?.map((childFolder) => {
       return (
@@ -256,7 +257,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
               </div>
             </>
           )}
-          { isOperable && (
+          {isOperable && (
             <div className="grw-foldertree-control d-flex">
               <BookmarkFolderItemControl
                 onClickRename={onClickRenameHandler}

+ 3 - 3
apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx

@@ -120,7 +120,7 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
           className={'grw-bookmark-folder-menu-item text-danger'}
         >
           <i className="fa fa-bookmark"></i>{' '}
-          <span className="mx-2 ">
+          <span className="mx-2">
             {t('bookmark_folder.cancel_bookmark')}
           </span>
         </DropdownItem>
@@ -143,7 +143,7 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
               </div>
             </div>
             {bookmarkFolders?.map(folder => (
-              <div key={folder._id}>
+              <React.Fragment key={`bookmark-folders-${folder._id}`}>
                 <div
                   className="dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action border-0 py-0"
                   style={{ paddingLeft: '40px' }}
@@ -174,7 +174,7 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
                     </div>
                   </div>
                 ))}
-              </div>
+              </React.Fragment>
             ))}
           </>
         )}

+ 10 - 6
apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx

@@ -2,6 +2,7 @@
 import React, { useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
 
 import { toastSuccess } from '~/client/util/toastr';
 import { IPageToDeleteWithMeta } from '~/interfaces/page';
@@ -12,7 +13,7 @@ import {
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
 import { useIsReadOnlyUser } from '~/stores/context';
 import { usePageDeleteModal } from '~/stores/modal';
-import { useSWRMUTxPageInfo, useSWRxCurrentPage } from '~/stores/page';
+import { mutateAllPageInfo, useSWRMUTxPageInfo, useSWRxCurrentPage } from '~/stores/page';
 
 import { BookmarkFolderItem } from './BookmarkFolderItem';
 import { BookmarkItem } from './BookmarkItem';
@@ -36,6 +37,7 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
 
   // const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
   const { t } = useTranslation();
+  const router = useRouter();
 
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: currentPage } = useSWRxCurrentPage();
@@ -55,13 +57,15 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
   const onClickDeleteMenuItemHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
     const pageDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, _isRecursively, isCompletely) => {
       if (typeof pathOrPathsToDelete !== 'string') return;
-
-      toastSuccess(isCompletely ? t('deleted_pages_completely', { pathOrPathsToDelete }) : t('deleted_pages', { pathOrPathsToDelete }));
-
+      toastSuccess(isCompletely ? t('deleted_pages_completely', { path: pathOrPathsToDelete }) : t('deleted_pages', { path: pathOrPathsToDelete }));
       bookmarkFolderTreeMutation();
+      mutateAllPageInfo();
+      if (pageToDelete.data._id === currentPage?._id && _isRecursively) {
+        router.push(`/trash${currentPage.path}`);
+      }
     };
     openDeleteModal([pageToDelete], { onDeleted: pageDeletedHandler });
-  }, [openDeleteModal, t, bookmarkFolderTreeMutation]);
+  }, [openDeleteModal, t, bookmarkFolderTreeMutation, currentPage?._id, currentPage?.path, router]);
 
   /* TODO: update in bookmarks folder v2. */
   // const itemDropHandler = async(item: DragItemDataType, dragType: string | null | symbol) => {
@@ -98,7 +102,7 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
   // };
 
   return (
-    <div className={`grw-folder-tree-container ${styles['grw-folder-tree-container']}` } >
+    <div className={`grw-folder-tree-container ${styles['grw-folder-tree-container']}`} >
       <ul className={`grw-foldertree ${styles['grw-foldertree']} list-group px-2 py-2`}>
         {bookmarkFolders?.map((bookmarkFolder) => {
           return (

+ 42 - 16
apps/app/src/components/Bookmarks/BookmarkItem.tsx

@@ -3,16 +3,19 @@ import React, { useCallback, useState } from 'react';
 import nodePath from 'path';
 
 import { DevidedPagePath, pathUtils } from '@growi/core';
+import { useRouter } from 'next/router';
 import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip, DropdownToggle } from 'reactstrap';
 
-import { bookmark, unbookmark } from '~/client/services/page-operation';
+
+import { bookmark, unbookmark, unlink } from '~/client/services/page-operation';
 import { addBookmarkToFolder, renamePage } from '~/client/util/bookmark-utils';
 import { ValidationTarget } from '~/client/util/input-validator';
-import { toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { BookmarkFolderItems, DragItemDataType, DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
 import { IPageHasId, IPageInfoAll, IPageToDeleteWithMeta } from '~/interfaces/page';
-import { useSWRxPageInfo } from '~/stores/page';
+import { usePutBackPageModal } from '~/stores/modal';
+import { mutateAllPageInfo, useSWRMUTxCurrentPage, useSWRxPageInfo } from '~/stores/page';
 
 import ClosableTextInput from '../Common/ClosableTextInput';
 import { MenuItemType, PageItemControl } from '../Common/Dropdown/PageItemControl';
@@ -29,7 +32,7 @@ type Props = {
   parentFolder: BookmarkFolderItems | null,
   canMoveToRoot: boolean,
   onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void,
-  bookmarkFolderTreeMutation: () => void
+  bookmarkFolderTreeMutation: () => void,
 }
 
 export const BookmarkItem = (props: Props): JSX.Element => {
@@ -37,15 +40,17 @@ export const BookmarkItem = (props: Props): JSX.Element => {
   const BASE_BOOKMARK_PADDING = 20;
 
   const { t } = useTranslation();
+  const router = useRouter();
 
   const {
     isReadOnlyUser, isOperable, bookmarkedPage, onClickDeleteMenuItemHandler,
     parentFolder, level, canMoveToRoot, bookmarkFolderTreeMutation,
   } = props;
-
+  const { open: openPutBackPageModal } = usePutBackPageModal();
   const [isRenameInputShown, setRenameInputShown] = useState(false);
 
   const { data: pageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(bookmarkedPage._id);
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const dPagePath = new DevidedPagePath(bookmarkedPage.path, false, true);
   const { latter: pageTitle, former: formerPagePath } = dPagePath;
   const bookmarkItemId = `bookmark-item-${bookmarkedPage._id}`;
@@ -116,6 +121,24 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     onClickDeleteMenuItemHandler(pageToDelete);
   }, [bookmarkedPage._id, bookmarkedPage.path, bookmarkedPage.revision, onClickDeleteMenuItemHandler]);
 
+  const putBackClickHandler = useCallback(() => {
+    const { _id: pageId, path } = bookmarkedPage;
+    const putBackedHandler = async() => {
+      try {
+        await unlink(path);
+        mutateAllPageInfo();
+        bookmarkFolderTreeMutation();
+        router.push(`/${pageId}`);
+        mutateCurrentPage();
+        toastSuccess(t('page_has_been_reverted', { path }));
+      }
+      catch (err) {
+        toastError(err);
+      }
+    };
+    openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
+  }, [bookmarkedPage, openPutBackPageModal, bookmarkFolderTreeMutation, router, mutateCurrentPage, t]);
+
   return (
     <DragAndDropWrapper
       item={dragItem}
@@ -128,15 +151,17 @@ export const BookmarkItem = (props: Props): JSX.Element => {
         id={bookmarkItemId}
         style={{ paddingLeft }}
       >
-        { isRenameInputShown ? (
-          <ClosableTextInput
-            value={nodePath.basename(bookmarkedPage.path ?? '')}
-            placeholder={t('Input page name')}
-            onClickOutside={() => { setRenameInputShown(false) }}
-            onPressEnter={pressEnterForRenameHandler}
-            validationTarget={ValidationTarget.PAGE}
-          />
-        ) : <PageListItemS page={bookmarkedPage} pageTitle={pageTitle}/>}
+        { isRenameInputShown
+          ? (
+            <ClosableTextInput
+              value={nodePath.basename(bookmarkedPage.path ?? '')}
+              placeholder={t('Input page name')}
+              onClickOutside={() => { setRenameInputShown(false) }}
+              onPressEnter={pressEnterForRenameHandler}
+              validationTarget={ValidationTarget.PAGE}
+            />
+          )
+          : <PageListItemS page={bookmarkedPage} pageTitle={pageTitle} />}
 
         <div className='grw-foldertree-control'>
           <PageItemControl
@@ -148,8 +173,9 @@ export const BookmarkItem = (props: Props): JSX.Element => {
             onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
             onClickRenameMenuItem={renameMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
-            additionalMenuItemOnTopRenderer={canMoveToRoot && isOperable
-              ? () => <BookmarkMoveToRootBtn pageId={bookmarkedPage._id} onClickMoveToRootHandler={onClickMoveToRootHandler}/>
+            onClickRevertMenuItem={putBackClickHandler}
+            additionalMenuItemOnTopRenderer={canMoveToRoot
+              ? () => <BookmarkMoveToRootBtn pageId={bookmarkedPage._id} onClickMoveToRootHandler={onClickMoveToRootHandler} />
               : undefined}
           >
             <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">

+ 1 - 6
apps/app/src/components/DescendantsPageList.tsx

@@ -10,9 +10,7 @@ import {
 } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
 import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
-import {
-  useIsGuestUser, useIsReadOnlyUser, useIsSharedUser,
-} from '~/stores/context';
+import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/stores/context';
 import {
   mutatePageTree,
   useSWRxPageInfoForList, useSWRxPageList,
@@ -22,7 +20,6 @@ import { ForceHideMenuItems } from './Common/Dropdown/PageItemControl';
 import PageList from './PageList/PageList';
 import PaginationWrapper from './PaginationWrapper';
 
-
 type SubstanceProps = {
   pagingResult: IPagingResult<IPageHasId> | undefined,
   activePage: number,
@@ -71,7 +68,6 @@ const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
     }
 
     mutatePageTree();
-
     if (onPagesDeleted != null) {
       onPagesDeleted(...args);
     }
@@ -81,7 +77,6 @@ const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
     toastSuccess(t('page_has_been_reverted', { path }));
 
     mutatePageTree();
-
     if (onPagePutBacked != null) {
       onPagePutBacked(path);
     }

+ 6 - 16
apps/app/src/components/InstallerForm.tsx

@@ -19,7 +19,7 @@ const InstallerForm = memo((): JSX.Element => {
   const isSupportedLang = AllLang.includes(i18n.language as Lang);
 
   const [isValidUserName, setValidUserName] = useState(true);
-  const [isSubmittingDisabled, setSubmittingDisabled] = useState(false);
+  const [isLoading, setIsLoading] = useState(false);
   const [currentLocale, setCurrentLocale] = useState(isSupportedLang ? i18n.language : Lang.en_US);
 
   const checkUserName = useCallback(async(event) => {
@@ -42,18 +42,7 @@ const InstallerForm = memo((): JSX.Element => {
   const submitHandler: FormEventHandler = useCallback(async(e: any) => {
     e.preventDefault();
 
-    if (isSubmittingDisabled) {
-      return;
-    }
-
-    setSubmittingDisabled(true);
-    setTimeout(() => {
-      setSubmittingDisabled(false);
-    }, 3000);
-
-    if (e.target.elements == null) {
-      return;
-    }
+    setIsLoading(true);
 
     const formData = e.target.elements;
 
@@ -81,6 +70,7 @@ const InstallerForm = memo((): JSX.Element => {
     catch (errs) {
       const err = errs[0];
       const code = err.code;
+      setIsLoading(false);
 
       if (code === 'failed_to_login_after_install') {
         toastError(t('installer.failed_to_login_after_install'));
@@ -89,7 +79,7 @@ const InstallerForm = memo((): JSX.Element => {
 
       toastError(t('installer.failed_to_install'));
     }
-  }, [isSubmittingDisabled, currentLocale, router, t]);
+  }, [currentLocale, router, t]);
 
   const hasErrorClass = isValidUserName ? '' : ' has-error';
   const unavailableUserId = isValidUserName
@@ -220,10 +210,10 @@ const InstallerForm = memo((): JSX.Element => {
               type="submit"
               className="btn-fill btn btn-register"
               id="register"
-              disabled={isSubmittingDisabled}
+              disabled={isLoading}
             >
               <div className="eff"></div>
-              <span className="btn-label"><i className="icon-user-follow" /></span>
+              <span className="btn-label"><i className={isLoading ? 'fa fa-spinner fa-pulse mr-1' : 'icon-user-follow'} /></span>
               <span className="btn-label-text">{ t('Create') }</span>
             </button>
           </div>

+ 6 - 13
apps/app/src/components/InvitedForm.tsx

@@ -18,13 +18,14 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
   const { t } = useTranslation();
   const router = useRouter();
   const { data: user } = useCurrentUser();
-  const [isConnectSuccess, setIsConnectSuccess] = useState<boolean>(false);
   const [loginErrors, setLoginErrors] = useState<Error[]>([]);
+  const [isLoading, setIsLoading] = useState(false);
 
   const { invitedFormUsername, invitedFormName } = props;
 
   const submitHandler = useCallback(async(e) => {
     e.preventDefault();
+    setIsLoading(true);
 
     const formData = e.target.elements;
 
@@ -42,25 +43,17 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
 
     try {
       const res = await apiv3Post('/invited', { invitedForm });
-      setIsConnectSuccess(true);
       const { redirectTo } = res.data;
       router.push(redirectTo ?? '/');
     }
     catch (err) {
       setLoginErrors(err);
+      setIsLoading(false);
     }
   }, [router]);
 
   const formNotification = useCallback(() => {
 
-    if (isConnectSuccess) {
-      return (
-        <p className="alert alert-success">
-          <strong>{ t('message.successfully_connected') }</strong><br></br>
-        </p>
-      );
-    }
-
     return (
       <>
         { loginErrors != null && loginErrors.length > 0 ? (
@@ -77,7 +70,7 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
         ) }
       </>
     );
-  }, [isConnectSuccess, loginErrors, t]);
+  }, [loginErrors, t]);
 
   if (user == null) {
     return <></>;
@@ -154,9 +147,9 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
         </div>
         {/* Create Button */}
         <div className="input-group justify-content-center d-flex mt-4">
-          <button type="submit" className="btn btn-fill" id="register">
+          <button type="submit" className="btn btn-fill" id="register" disabled={isLoading}>
             <div className="eff"></div>
-            <span className="btn-label"><i className="icon-user-follow"></i></span>
+            <span className="btn-label"><i className={isLoading ? 'fa fa-spinner fa-pulse mr-1' : 'icon-user-follow'} /></span>
             <span className="btn-label-text">{t('Create')}</span>
           </button>
         </div>

+ 1 - 1
apps/app/src/components/Layout/AdminLayout.tsx

@@ -2,6 +2,7 @@ import React, { ReactNode } from 'react';
 
 import dynamic from 'next/dynamic';
 
+import { AdminNavigation } from '../Admin/Common/AdminNavigation';
 import { GrowiNavbar } from '../Navbar/GrowiNavbar';
 
 import { RawLayout } from './RawLayout';
@@ -9,7 +10,6 @@ import { RawLayout } from './RawLayout';
 import styles from './Admin.module.scss';
 
 
-const AdminNavigation = dynamic(() => import('~/components/Admin/Common/AdminNavigation'), { ssr: false });
 const PageCreateModal = dynamic(() => import('../PageCreateModal'), { ssr: false });
 const SystemVersion = dynamic(() => import('../SystemVersion'), { ssr: false });
 const HotkeysManager = dynamic(() => import('../Hotkeys/HotkeysManager'), { ssr: false });

+ 4 - 2
apps/app/src/components/Layout/BasicLayout.tsx

@@ -16,13 +16,14 @@ const HotkeysManager = dynamic(() => import('../Hotkeys/HotkeysManager'), { ssr:
 const GrowiNavbarBottom = dynamic(() => import('../Navbar/GrowiNavbarBottom').then(mod => mod.GrowiNavbarBottom), { ssr: false });
 const ShortcutsModal = dynamic(() => import('../ShortcutsModal'), { ssr: false });
 const SystemVersion = dynamic(() => import('../SystemVersion'), { ssr: false });
+const PutbackPageModal = dynamic(() => import('../PutbackPageModal'), { ssr: false });
 // Page modals
 const PageCreateModal = dynamic(() => import('../PageCreateModal'), { ssr: false });
 const PageDuplicateModal = dynamic(() => import('../PageDuplicateModal'), { ssr: false });
 const PageDeleteModal = dynamic(() => import('../PageDeleteModal'), { ssr: false });
 const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false });
 const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
-const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal'), { ssr: false });
+const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal').then(mod => mod.PageAccessoriesModal), { ssr: false });
 const DeleteBookmarkFolderModal = dynamic(() => import('../DeleteBookmarkFolderModal').then(mod => mod.DeleteBookmarkFolderModal), { ssr: false });
 // Fab
 const Fab = dynamic(() => import('../Fab').then(mod => mod.Fab), { ssr: false });
@@ -40,7 +41,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
         <GrowiNavbar />
 
         <div className="page-wrapper d-flex d-print-block">
-          <div className="grw-sidebar-wrapper" data-testid="grw-sidebar-wrapper">
+          <div className="grw-sidebar-wrapper">
             <Sidebar />
           </div>
 
@@ -59,6 +60,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
         <PageAccessoriesModal />
         <DeleteAttachmentModal />
         <DeleteBookmarkFolderModal />
+        <PutbackPageModal />
       </DndProvider>
 
       <PagePresentationModal />

+ 46 - 9
apps/app/src/components/LoginForm.tsx

@@ -48,6 +48,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
   // states
   const [isRegistering, setIsRegistering] = useState(false);
+  const [isLoading, setIsLoading] = useState(false);
   // For Login
   const [usernameForLogin, setUsernameForLogin] = useState('');
   const [passwordForLogin, setPasswordForLogin] = useState('');
@@ -93,6 +94,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
   const handleLoginWithLocalSubmit = useCallback(async(e) => {
     e.preventDefault();
     resetLoginErrors();
+    setIsLoading(true);
 
     const loginForm = {
       username: usernameForLogin,
@@ -112,6 +114,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     catch (err) {
       const errs = toArrayIfNot(err);
       setLoginErrors(errs);
+      setIsLoading(false);
     }
     return;
 
@@ -176,6 +179,12 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
     return (
       <>
+        {/* !! - DO NOT DELETE HIDDEN ELEMENT - !! -- 7.12 ryoji-s */}
+        {/* Import font-awesome to prevent MongoStore.js "Unable to find the session to touch" error */}
+        <div className='sr-only'>
+          <i className="fa fa-spinner fa-pulse" />
+        </div>
+        {/* !! - END OF HIDDEN ELEMENT - !! */}
         {isLdapSetupFailed && (
           <div className="alert alert-warning small">
             <strong><i className="icon-fw icon-info"></i>{t('login.enabled_ldap_has_configuration_problem')}</strong><br/>
@@ -214,10 +223,16 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
           </div>
 
           <div className="input-group my-4">
-            <button type="submit" id="login" className="btn btn-fill rounded-0 login mx-auto" data-testid="btnSubmitForLogin">
+            <button
+              type="submit"
+              id="login"
+              className="btn btn-fill rounded-0 login mx-auto"
+              data-testid="btnSubmitForLogin"
+              disabled={isLoading}
+            >
               <div className="eff"></div>
               <span className="btn-label">
-                <i className="icon-login"></i>
+                <i className={isLoading ? 'fa fa-spinner fa-pulse mr-1' : 'icon-login'} />
               </span>
               <span className="btn-label-text">{t('Sign in')}</span>
             </button>
@@ -225,8 +240,18 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
         </form>
       </>
     );
-  }, [generateDangerouslySetErrors, generateSafelySetErrors, handleLoginWithLocalSubmit,
-      isLdapSetupFailed, loginErrors, props, separateErrorsBasedOnErrorCode, t]);
+  }, [
+    props,
+    separateErrorsBasedOnErrorCode,
+    loginErrors,
+    generateDangerouslySetErrors,
+    generateSafelySetErrors,
+    isLdapSetupFailed,
+    t,
+    handleLoginWithLocalSubmit,
+    isLoading,
+  ]);
+
 
   const renderExternalAuthInput = useCallback((auth) => {
     const authIconNames = {
@@ -295,6 +320,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     e.preventDefault();
     setEmailForRegistrationOrder('');
     setIsSuccessToRagistration(false);
+    setIsLoading(true);
 
     const registerForm = {
       username: usernameForRegister,
@@ -323,6 +349,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
       if (err != null || err.length > 0) {
         setRegisterErrors(err);
       }
+      setIsLoading(false);
     }
     return;
   }, [usernameForRegister, nameForRegister, emailForRegister, passwordForRegister, resetRegisterErrors, router, isEmailAuthenticationEnabled]);
@@ -478,11 +505,11 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
             <button
               className="btn btn-fill rounded-0"
               id="register"
-              disabled={(!isMailerSetup && isEmailAuthenticationEnabled)}
+              disabled={(!isMailerSetup && isEmailAuthenticationEnabled) || isLoading}
             >
               <div className="eff"></div>
               <span className="btn-label">
-                <i className="icon-user-follow"></i>
+                <i className={isLoading ? 'fa fa-spinner fa-pulse mr-1' : 'icon-user-follow'} />
               </span>
               <span className="btn-label-text">{submitText}</span>
             </button>
@@ -493,7 +520,12 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
         <div className="row">
           <div className="text-right col-12 mt-2 py-2">
-            <a href="#login" id="login" className="link-switch" onClick={switchForm}>
+            <a
+              href="#login"
+              id="login"
+              className="link-switch"
+              style={{ pointerEvents: isLoading ? 'none' : 'auto' }}
+              onClick={switchForm}>
               <i className="icon-fw icon-login"></i>
               {t('Sign in is here')}
             </a>
@@ -503,7 +535,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     );
   }, [
     t, isEmailAuthenticationEnabled, registrationMode, isMailerSetup, registerErrors, isSuccessToRagistration,
-    emailForRegistrationOrder, props.username, props.name, props.email, registrationWhitelist, switchForm, handleRegisterFormSubmit,
+    emailForRegistrationOrder, props.username, props.name, props.email, registrationWhitelist, switchForm, handleRegisterFormSubmit, isLoading,
   ]);
 
   if (registrationMode === RegistrationMode.RESTRICTED && isSuccessToRagistration && !isEmailAuthenticationEnabled) {
@@ -529,7 +561,12 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
                 {/* Sign up link */}
                 {isRegistrationEnabled && (
                   <div className="text-right mb-2">
-                    <a href="#register" id="register" className="link-switch" onClick={switchForm}>
+                    <a
+                      href="#register"
+                      id="register"
+                      className="link-switch"
+                      style={{ pointerEvents: isLoading ? 'none' : 'auto' }}
+                      onClick={switchForm}>
                       <i className="ti ti-check-box"></i> {t('Sign up is here')}
                     </a>
                   </div>

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

@@ -1,5 +1,6 @@
 import React, { useCallback } from 'react';
 
+import type { IExternalAccountHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import {
   Modal,
@@ -9,13 +10,12 @@ import {
 } from 'reactstrap';
 
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { IExternalAccount } from '~/interfaces/external-account';
 import { usePersonalSettings, useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
 
 type Props = {
   isOpen: boolean,
   onClose: () => void,
-  accountForDisassociate: IExternalAccount,
+  accountForDisassociate: IExternalAccountHasId,
 }
 
 

+ 19 - 17
apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -25,7 +25,7 @@ import {
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, usePagePresentationModal,
 } from '~/stores/modal';
 import {
-  useSWRMUTxCurrentPage, useSWRxTagsInfo, useCurrentPageId, useIsNotFound, useTemplateTagData,
+  useSWRMUTxCurrentPage, useSWRxTagsInfo, useCurrentPageId, useIsNotFound, useTemplateTagData, useSWRxPageInfo,
 } from '~/stores/page';
 import {
   EditorMode, useDrawerMode, useEditorMode, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
@@ -98,7 +98,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         <i className="icon-fw grw-page-control-dropdown-icon">
           <PresentationIcon />
         </i>
-        { t('Presentation Mode') }
+        {t('Presentation Mode')}
       </DropdownItem>
 
       {/* Export markdown */}
@@ -139,7 +139,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         {t('attachment_data')}
       </DropdownItem>
 
-      { !isGuestUser && !isReadOnlyUser && !isSharedUser && (
+      {!isGuestUser && !isReadOnlyUser && !isSharedUser && (
         <NotAvailable isDisabled={isLinkSharingDisabled ?? false} title="Disabled by admin">
           <DropdownItem
             onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
@@ -152,7 +152,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
             {t('share_links.share_link_management')}
           </DropdownItem>
         </NotAvailable>
-      ) }
+      )}
     </>
   );
 };
@@ -179,7 +179,7 @@ const CreateTemplateMenuItems = (props: CreateTemplateMenuItemsProps): JSX.Eleme
         data-testid="open-page-template-modal-btn"
       >
         <i className="icon-fw icon-magic-wand grw-page-control-dropdown-icon"></i>
-        { t('template.option_label.create/edit') }
+        {t('template.option_label.create/edit')}
       </DropdownItem>
     </>
   );
@@ -231,6 +231,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { data: templateTagData } = useTemplateTagData();
+  const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId);
 
   const updateStateAfterSave = useUpdateStateAfterSave(pageId);
 
@@ -319,9 +320,10 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       }
 
       mutateCurrentPage();
+      mutatePageInfo();
     };
     openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
-  }, [currentPathname, mutateCurrentPage, openDeleteModal, router]);
+  }, [currentPathname, mutateCurrentPage, openDeleteModal, router, mutatePageInfo]);
 
   const switchContentWidthHandler = useCallback(async(pageId: string, value: boolean) => {
     if (!isSharedPage) {
@@ -341,9 +343,9 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
         return (
           <>
             {!isReadOnlyUser
-            && <CreateTemplateMenuItems
-              onClickTemplateMenuItem={templateMenuItemClickHandler}
-            />
+              && <CreateTemplateMenuItems
+                onClickTemplateMenuItem={templateMenuItemClickHandler}
+              />
             }
           </>);
       }
@@ -368,9 +370,9 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       <>
         <div className="d-flex">
           <div className="d-flex flex-column align-items-end justify-content-center py-md-2" style={{ gap: `${isCompactMode ? '5px' : '7px'}` }}>
-            { isViewMode && (
+            {isViewMode && (
               <div className="h-50">
-                { pageId != null && (
+                {pageId != null && (
                   <SubNavButtons
                     isCompactMode={isCompactMode}
                     pageId={pageId}
@@ -386,9 +388,9 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
                     onClickDeleteMenuItem={deleteItemClickedHandler}
                     onClickSwitchContentWidth={switchContentWidthHandler}
                   />
-                ) }
+                )}
               </div>
-            ) }
+            )}
             {isAbleToChangeEditorMode && (
               <PageEditorModeManager
                 onPageEditorModeButtonClicked={viewType => mutateEditorMode(viewType)}
@@ -397,22 +399,22 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
               />
             )}
           </div>
-          { (isAbleToShowPageAuthors && !isCompactMode && !isUsersHomePage(path ?? '')) && (
+          {(isAbleToShowPageAuthors && !isCompactMode && !isUsersHomePage(path ?? '')) && (
             <ul className={`${AuthorInfoStyles['grw-author-info']} text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3`}>
               <li className="pb-1">
-                { currentPage != null
+                {currentPage != null
                   ? <AuthorInfo user={currentPage.creator as IUser} date={currentPage.createdAt} mode="create" locate="subnav" />
                   : <AuthorInfoSkeleton />
                 }
               </li>
               <li className="mt-1 pt-1 border-top">
-                { currentPage != null
+                {currentPage != null
                   ? <AuthorInfo user={currentPage.lastUpdateUser as IUser} date={currentPage.updatedAt} mode="update" locate="subnav" />
                   : <AuthorInfoSkeleton />
                 }
               </li>
             </ul>
-          ) }
+          )}
         </div>
 
         {path != null && currentUser != null && !isReadOnlyUser && (

+ 6 - 3
apps/app/src/components/Navbar/SubNavButtons.tsx

@@ -202,8 +202,11 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
     sumOfLikers, sumOfSeenUsers, isLiked,
   } = pageInfo;
 
-  const forceHideMenuItemsWithBookmark = forceHideMenuItems ?? [];
-  forceHideMenuItemsWithBookmark.push(MenuItemType.BOOKMARK);
+  const forceHideMenuItemsWithAdditions = [
+    ...(forceHideMenuItems ?? []),
+    MenuItemType.BOOKMARK,
+    MenuItemType.REVERT,
+  ];
 
   return (
     <div className="d-flex" style={{ gap: '2px' }}>
@@ -244,7 +247,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
           pageInfo={pageInfo}
           isEnableActions={!isGuestUser}
           isReadOnlyUser={!!isReadOnlyUser}
-          forceHideMenuItems={forceHideMenuItemsWithBookmark}
+          forceHideMenuItems={forceHideMenuItemsWithAdditions}
           additionalMenuItemOnTopRenderer={!isReadOnlyUser ? additionalMenuItemOnTopRenderer : undefined}
           additionalMenuItemRenderer={additionalMenuItemRenderer}
           onClickRenameMenuItem={renameMenuItemClickHandler}

+ 1 - 1
apps/app/src/components/Page/PageView.tsx

@@ -83,7 +83,7 @@ export const PageView = (props: Props): JSX.Element => {
 
     const targetId = hash.slice(1);
 
-    const target = document.getElementById(targetId);
+    const target = document.getElementById(decodeURIComponent(targetId));
     target?.scrollIntoView();
 
   }, [isCommentsLoaded]);

+ 0 - 0
apps/app/src/components/PageAccessoriesModal.module.scss → apps/app/src/components/PageAccessoriesModal/PageAccessoriesModal.module.scss


+ 25 - 57
apps/app/src/components/PageAccessoriesModal.tsx → apps/app/src/components/PageAccessoriesModal/PageAccessoriesModal.tsx

@@ -1,6 +1,7 @@
-import React, { useEffect, useMemo, useState } from 'react';
+import React, { useMemo, useState } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
 import {
   Modal, ModalBody, ModalHeader,
 } from 'reactstrap';
@@ -10,25 +11,26 @@ import {
 } from '~/stores/context';
 import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/modal';
 
-import { CustomNavTab } from './CustomNavigation/CustomNav';
-import CustomTabContent from './CustomNavigation/CustomTabContent';
-import ExpandOrContractButton from './ExpandOrContractButton';
-import AttachmentIcon from './Icons/AttachmentIcon';
-import HistoryIcon from './Icons/HistoryIcon';
-import ShareLinkIcon from './Icons/ShareLinkIcon';
-import PageAttachment from './PageAttachment';
-import { PageHistory, getQueryParam } from './PageHistory';
-import ShareLink from './ShareLink/ShareLink';
+import { CustomNavTab } from '../CustomNavigation/CustomNav';
+import CustomTabContent from '../CustomNavigation/CustomTabContent';
+import ExpandOrContractButton from '../ExpandOrContractButton';
+import AttachmentIcon from '../Icons/AttachmentIcon';
+import HistoryIcon from '../Icons/HistoryIcon';
+import ShareLinkIcon from '../Icons/ShareLinkIcon';
+
+import { useAutoOpenModalByQueryParam } from './hooks';
 
 import styles from './PageAccessoriesModal.module.scss';
 
-const PageAccessoriesModal = (): JSX.Element => {
 
-  const { t } = useTranslation();
+const PageAttachment = dynamic(() => import('./PageAttachment'), { ssr: false });
+const PageHistory = dynamic(() => import('./PageHistory').then(mod => mod.PageHistory), { ssr: false });
+const ShareLink = dynamic(() => import('./ShareLink').then(mod => mod.ShareLink), { ssr: false });
+
 
-  const [activeTab, setActiveTab] = useState<PageAccessoriesModalContents>();
-  const [sourceRevisionId, setSourceRevisionId] = useState<string>();
-  const [targetRevisionId, setTargetRevisionId] = useState<string>();
+export const PageAccessoriesModal = (): JSX.Element => {
+
+  const { t } = useTranslation();
 
   const [isWindowExpanded, setIsWindowExpanded] = useState(false);
 
@@ -37,46 +39,16 @@ const PageAccessoriesModal = (): JSX.Element => {
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isLinkSharingDisabled } = useDisableLinkSharing();
 
-  const { data: status, mutate, close } = usePageAccessoriesModal();
-
-  // activate tab when open
-  useEffect(() => {
-    if (status == null) return;
+  const { data: status, close, selectContents } = usePageAccessoriesModal();
 
-    const { isOpened, activatedContents } = status;
-    if (isOpened && activatedContents != null) {
-      setActiveTab(activatedContents);
-    }
-  }, [status]);
-
-  // Set sourceRevisionId and targetRevisionId as state with valid object id string
-  useEffect(() => {
-    const queryParams = getQueryParam();
-    // https://regex101.com/r/YHTDsr/1
-    const regex = /^([0-9a-f]{24})...([0-9a-f]{24})$/i;
-
-    if (queryParams == null || !regex.test(queryParams)) {
-      return;
-    }
-
-    const matches = queryParams.match(regex);
-
-    if (matches == null) {
-      return;
-    }
-
-    const [, sourceRevisionId, targetRevisionId] = matches;
-    setSourceRevisionId(sourceRevisionId);
-    setTargetRevisionId(targetRevisionId);
-    mutate({ isOpened: true });
-  }, [mutate]);
+  useAutoOpenModalByQueryParam();
 
   const navTabMapping = useMemo(() => {
     return {
       [PageAccessoriesModalContents.PageHistory]: {
         Icon: HistoryIcon,
         Content: () => {
-          return <PageHistory onClose={close} sourceRevisionId={sourceRevisionId} targetRevisionId={targetRevisionId}/>;
+          return <PageHistory onClose={close} />;
         },
         i18n: t('History'),
         isLinkEnabled: () => !isGuestUser && !isSharedUser,
@@ -97,7 +69,7 @@ const PageAccessoriesModal = (): JSX.Element => {
         isLinkEnabled: () => !isGuestUser && !isReadOnlyUser && !isSharedUser && !isLinkSharingDisabled,
       },
     };
-  }, [t, close, sourceRevisionId, targetRevisionId, isGuestUser, isReadOnlyUser, isSharedUser, isLinkSharingDisabled]);
+  }, [t, close, isGuestUser, isReadOnlyUser, isSharedUser, isLinkSharingDisabled]);
 
   const buttons = useMemo(() => (
     <div className="d-flex flex-nowrap">
@@ -112,7 +84,7 @@ const PageAccessoriesModal = (): JSX.Element => {
     </div>
   ), [close, isWindowExpanded]);
 
-  if (status == null || activeTab == null) {
+  if (status == null || status.activatedContents == null) {
     return <></>;
   }
 
@@ -128,20 +100,16 @@ const PageAccessoriesModal = (): JSX.Element => {
     >
       <ModalHeader className="p-0" toggle={close} close={buttons}>
         <CustomNavTab
-          activeTab={activeTab}
+          activeTab={status.activatedContents}
           navTabMapping={navTabMapping}
           breakpointToHideInactiveTabsDown="md"
-          onNavSelected={(v: PageAccessoriesModalContents) => {
-            setActiveTab(v);
-          }}
+          onNavSelected={selectContents}
           hideBorderBottom
         />
       </ModalHeader>
       <ModalBody className="overflow-auto grw-modal-body-style">
-        <CustomTabContent activeTab={activeTab} navTabMapping={navTabMapping} />
+        <CustomTabContent activeTab={status.activatedContents} navTabMapping={navTabMapping} />
       </ModalBody>
     </Modal>
   );
 };
-
-export default PageAccessoriesModal;

+ 2 - 2
apps/app/src/components/PageAttachment.tsx → apps/app/src/components/PageAccessoriesModal/PageAttachment.tsx

@@ -9,8 +9,8 @@ import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useDeleteAttachmentModal } from '~/stores/modal';
 import { useSWRxCurrentPage, useCurrentPageId } from '~/stores/page';
 
-import { PageAttachmentList } from './PageAttachment/PageAttachmentList';
-import PaginationWrapper from './PaginationWrapper';
+import { PageAttachmentList } from '../PageAttachment/PageAttachmentList';
+import PaginationWrapper from '../PaginationWrapper';
 
 // Utility
 const checkIfFileInUse = (markdown: string, attachment): boolean => {

+ 8 - 12
apps/app/src/components/PageHistory.tsx → apps/app/src/components/PageAccessoriesModal/PageHistory.tsx

@@ -3,34 +3,30 @@ import React from 'react';
 import { useCurrentPagePath, useCurrentPageId } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
-import { PageRevisionTable } from './PageHistory/PageRevisionTable';
+import { PageRevisionTable } from '../PageHistory/PageRevisionTable';
+
+import { useAutoComparingRevisionsByQueryParam } from './hooks';
 
 const logger = loggerFactory('growi:PageHistory');
 
 type PageHistoryProps = {
-  sourceRevisionId?: string,
-  targetRevisionId?: string
   onClose: () => void
 }
 
-// Get string from 'compare' query params
-export const getQueryParam = (): string | null => {
-  const query: URLSearchParams = new URL(window.location.href).searchParams;
-  return query.get('compare');
-};
-
 export const PageHistory: React.FC<PageHistoryProps> = (props: PageHistoryProps) => {
-  const { sourceRevisionId, targetRevisionId, onClose } = props;
+  const { onClose } = props;
 
   const { data: currentPageId } = useCurrentPageId();
   const { data: currentPagePath } = useCurrentPagePath();
 
+  const comparingRevisions = useAutoComparingRevisionsByQueryParam();
+
   return (
     <div className="revision-history" data-testid="page-history">
       {currentPageId != null && currentPagePath != null && (
         <PageRevisionTable
-          sourceRevisionId={sourceRevisionId}
-          targetRevisionId={targetRevisionId}
+          sourceRevisionId={comparingRevisions?.sourceRevisionId}
+          targetRevisionId={comparingRevisions?.targetRevisionId}
           currentPageId={currentPageId}
           currentPagePath={currentPagePath}
           onClose={onClose}

+ 1 - 3
apps/app/src/components/ShareLink/ShareLink.tsx → apps/app/src/components/PageAccessoriesModal/ShareLink/ShareLink.tsx

@@ -12,7 +12,7 @@ import { useSWRxSharelink } from '~/stores/share-link';
 import { ShareLinkForm } from './ShareLinkForm';
 import ShareLinkList from './ShareLinkList';
 
-const ShareLink = (): JSX.Element => {
+export const ShareLink = (): JSX.Element => {
   const { t } = useTranslation();
   const [isOpenShareLinkForm, setIsOpenShareLinkForm] = useState<boolean>(false);
 
@@ -73,5 +73,3 @@ const ShareLink = (): JSX.Element => {
     </div>
   );
 };
-
-export default ShareLink;

+ 0 - 0
apps/app/src/components/ShareLink/ShareLinkForm.tsx → apps/app/src/components/PageAccessoriesModal/ShareLink/ShareLinkForm.tsx


+ 1 - 1
apps/app/src/components/ShareLink/ShareLinkList.tsx → apps/app/src/components/PageAccessoriesModal/ShareLink/ShareLinkList.tsx

@@ -3,7 +3,7 @@ import React from 'react';
 import dateFnsFormat from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
 
-import CopyDropdown from '../Page/CopyDropdown';
+import CopyDropdown from '../../Page/CopyDropdown';
 
 
 type ShareLinkTrProps = {

+ 1 - 0
apps/app/src/components/PageAccessoriesModal/ShareLink/index.ts

@@ -0,0 +1 @@
+export * from './ShareLink';

+ 79 - 0
apps/app/src/components/PageAccessoriesModal/hooks.tsx

@@ -0,0 +1,79 @@
+import { useEffect, useState } from 'react';
+
+import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/modal';
+
+function getURLQueryParamValue(key: string) {
+// window.location.href is page URL;
+  const queryStr: URLSearchParams = new URL(window.location.href).searchParams;
+  return queryStr.get(key);
+}
+
+// https://regex101.com/r/YHTDsr/1
+const queryCompareFormat = /^([0-9a-f]{24})...([0-9a-f]{24})$/i;
+
+
+export const useAutoOpenModalByQueryParam = (): void => {
+  const [isArleadyMounted, setIsArleadyMounted] = useState(false);
+
+  const { data: status, open: openPageAccessories } = usePageAccessoriesModal();
+
+  useEffect(() => {
+    if (isArleadyMounted) {
+      return;
+    }
+
+    if (status == null || status.isOpened === true) {
+      return;
+    }
+
+    const pageIdParams = getURLQueryParamValue('compare');
+    if (pageIdParams != null) {
+      const matches = pageIdParams.match(queryCompareFormat);
+
+      if (matches == null) {
+        return;
+      }
+
+      // open History
+      openPageAccessories(PageAccessoriesModalContents.PageHistory);
+    }
+
+    setIsArleadyMounted(true);
+  }, [openPageAccessories, status, isArleadyMounted]);
+
+};
+
+type ComparingRevisionIds = {
+  sourceRevisionId: string,
+  targetRevisionId: string,
+}
+
+export const useAutoComparingRevisionsByQueryParam = (): ComparingRevisionIds | null => {
+  const [isArleadyMounted, setIsArleadyMounted] = useState(false);
+
+  const [sourceRevisionId, setSourceRevisionId] = useState<string>();
+  const [targetRevisionId, setTargetRevisionId] = useState<string>();
+
+  useEffect(() => {
+    if (isArleadyMounted) {
+      return;
+    }
+
+    const pageIdParams = getURLQueryParamValue('compare');
+    if (pageIdParams != null) {
+      const matches = pageIdParams.match(queryCompareFormat);
+
+      if (matches != null) {
+        const [, source, target] = matches;
+        setSourceRevisionId(source);
+        setTargetRevisionId(target);
+      }
+    }
+
+    setIsArleadyMounted(true);
+  }, [isArleadyMounted]);
+
+  return sourceRevisionId != null && targetRevisionId != null
+    ? { sourceRevisionId, targetRevisionId }
+    : null;
+};

+ 1 - 0
apps/app/src/components/PageAccessoriesModal/index.ts

@@ -0,0 +1 @@
+export * from './PageAccessoriesModal';

+ 4 - 4
apps/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -89,7 +89,7 @@ export const TrashPageAlert = (): JSX.Element => {
           data-toggle="modal"
           data-testid="put-back-button"
         >
-          <i className="icon-action-undo" aria-hidden="true"></i> { t('Put Back') }
+          <i className="icon-action-undo" aria-hidden="true"></i> {t('Put Back')}
         </button>
         <button
           type="button"
@@ -97,7 +97,7 @@ export const TrashPageAlert = (): JSX.Element => {
           disabled={!(pageInfo?.isAbleToDeleteCompletely ?? false)}
           onClick={openPageDeleteModalHandler}
         >
-          <i className="icon-fire" aria-hidden="true"></i> { t('Delete Completely') }
+          <i className="icon-fire" aria-hidden="true"></i> {t('Delete Completely')}
         </button>
       </>
     );
@@ -115,11 +115,11 @@ export const TrashPageAlert = (): JSX.Element => {
           <br />
           <UserPicture user={deleteUser} />
           <span className="ml-2">
-            Deleted by { deleteUser?.name } at <span data-vrt-blackout-datetime>{deletedAt ?? pageData?.updatedAt}</span>
+            Deleted by {deleteUser?.name} at <span data-vrt-blackout-datetime>{deletedAt ?? pageData?.updatedAt}</span>
           </span>
         </div>
         <div className="pt-1 d-flex align-items-end align-items-lg-center">
-          { isAbleToShowTrashPageManagementButtons && renderTrashPageManagementButtons()}
+          {isAbleToShowTrashPageManagementButtons && renderTrashPageManagementButtons()}
         </div>
       </div>
     </>

+ 21 - 21
apps/app/src/components/PageList/PageListItemL.tsx

@@ -213,9 +213,9 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                 linkedPagePath={linkedPagePathFormer}
                 linkedPagePathByHtml={linkedPagePathHighlightedFormer}
               />
-              { showPageUpdatedTime && (
+              {showPageUpdatedTime && (
                 <span className="page-list-updated-at text-muted">Last update: {lastUpdateDate}</span>
-              ) }
+              )}
             </div>
             <div className="d-flex align-items-center mb-1">
               {/* Picture */}
@@ -254,32 +254,32 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
 
               {/* doropdown icon includes page control buttons */}
               {hasBrowsingRights
-              && <div className="ml-auto">
-                <PageItemControl
-                  alignRight
-                  pageId={pageData._id}
-                  pageInfo={isIPageInfoForListing(pageMeta) ? pageMeta : undefined}
-                  isEnableActions={isEnableActions}
-                  isReadOnlyUser={isReadOnlyUser}
-                  forceHideMenuItems={forceHideMenuItems}
-                  onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
-                  onClickRenameMenuItem={renameMenuItemClickHandler}
-                  onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
-                  onClickDeleteMenuItem={deleteMenuItemClickHandler}
-                  onClickRevertMenuItem={revertMenuItemClickHandler}
-                />
-              </div>
+                && <div className="ml-auto">
+                  <PageItemControl
+                    alignRight
+                    pageId={pageData._id}
+                    pageInfo={isIPageInfoForListing(pageMeta) ? pageMeta : undefined}
+                    isEnableActions={isEnableActions}
+                    isReadOnlyUser={isReadOnlyUser}
+                    forceHideMenuItems={forceHideMenuItems}
+                    onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
+                    onClickRenameMenuItem={renameMenuItemClickHandler}
+                    onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
+                    onClickDeleteMenuItem={deleteMenuItemClickHandler}
+                    onClickRevertMenuItem={revertMenuItemClickHandler}
+                  />
+                </div>
               }
             </div>
             <div className="page-list-snippet py-1">
               <Clamp lines={2}>
-                { elasticSearchResult != null && elasticSearchResult.snippet != null && (
+                {elasticSearchResult != null && elasticSearchResult.snippet != null && (
                   // eslint-disable-next-line react/no-danger
                   <div dangerouslySetInnerHTML={{ __html: elasticSearchResult.snippet }}></div>
-                ) }
-                { revisionShortBody != null && (
+                )}
+                {revisionShortBody != null && (
                   <div data-testid="revision-short-body-in-page-list-item-L">{revisionShortBody}</div>
-                ) }
+                )}
                 {
                   !hasBrowsingRights && (
                     <>

+ 6 - 3
apps/app/src/components/PageList/PageListItemS.tsx

@@ -3,6 +3,7 @@ import React from 'react';
 import { PageListMeta } from '@growi/ui/dist/components/PagePath/PageListMeta';
 import { PagePathLabel } from '@growi/ui/dist/components/PagePath/PagePathLabel';
 import { UserPicture } from '@growi/ui/dist/components/User/UserPicture';
+import Link from 'next/link';
 
 import { IPageHasId } from '~/interfaces/page';
 
@@ -10,18 +11,20 @@ import { IPageHasId } from '~/interfaces/page';
 type PageListItemSProps = {
   page: IPageHasId,
   noLink?: boolean,
-  pageTitle?: string
+  pageTitle?: string,
 }
 
 export const PageListItemS = (props: PageListItemSProps): JSX.Element => {
 
-  const { page, noLink = false, pageTitle } = props;
+  const {
+    page, noLink = false, pageTitle,
+  } = props;
 
   const path = pageTitle != null ? pageTitle : page.path;
 
   let pagePathElement = <PagePathLabel path={path} additionalClassNames={['mx-1']} />;
   if (!noLink) {
-    pagePathElement = <a className="text-break" href={page.path}>{pagePathElement}</a>;
+    pagePathElement = <Link href={`/${page._id}`} className="text-break" prefetch={false}>{pagePathElement}</Link>;
   }
 
   return (

+ 1 - 1
apps/app/src/components/ReactMarkdownComponents/Header.tsx

@@ -75,7 +75,7 @@ export const Header = (props: HeaderProps): JSX.Element => {
   const activateByHash = useCallback((url: string) => {
     try {
       const hash = (new URL(url, 'https://example.com')).hash.slice(1);
-      setActive(hash === id);
+      setActive(decodeURIComponent(hash) === id);
     }
     catch (err) {
       logger.debug(err);

+ 20 - 7
apps/app/src/components/ReactMarkdownComponents/NextLink.tsx

@@ -1,3 +1,4 @@
+import { pagePathUtils } from '@growi/core';
 import Link, { LinkProps } from 'next/link';
 
 import { useSiteUrl } from '~/stores/context';
@@ -22,6 +23,18 @@ const isExternalLink = (href: string, siteUrl: string | undefined): boolean => {
   }
 };
 
+const isCreatablePage = (href: string) => {
+  try {
+    const url = new URL(href);
+    const pathName = url.pathname;
+    return pagePathUtils.isCreatablePage(pathName);
+  }
+  catch (err) {
+    logger.debug(err);
+    return false;
+  }
+};
+
 type Props = Omit<LinkProps, 'href'> & {
   children: React.ReactNode,
   id?: string,
@@ -45,13 +58,6 @@ export const NextLink = (props: Props): JSX.Element => {
     Object.entries(rest).filter(([key]) => key.startsWith('data-')),
   );
 
-  // when href is an anchor link
-  if (isAnchorLink(href)) {
-    return (
-      <a id={id} href={href} className={className} {...dataAttributes}>{children}</a>
-    );
-  }
-
   if (isExternalLink(href, siteUrl)) {
     return (
       <a id={id} href={href} className={className} target="_blank" rel="noopener noreferrer" {...dataAttributes}>
@@ -60,6 +66,13 @@ export const NextLink = (props: Props): JSX.Element => {
     );
   }
 
+  // when href is an anchor link or not-creatable path
+  if (isAnchorLink(href) || !isCreatablePage(href)) {
+    return (
+      <a id={id} href={href} className={className} {...dataAttributes}>{children}</a>
+    );
+  }
+
   return (
     <Link {...rest} href={href} prefetch={false} legacyBehavior>
       <a href={href} className={className} {...dataAttributes}>{children}</a>

+ 1 - 2
apps/app/src/components/SearchPage/SearchPageBase.tsx

@@ -17,8 +17,6 @@ import { mutatePageTree } from '~/stores/page-listing';
 
 import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
-import { SearchResultList } from './SearchResultList';
-
 import styles from './SearchPageBase.module.scss';
 
 // https://regex101.com/r/brrkBu/1
@@ -44,6 +42,7 @@ type Props = {
 }
 
 
+const SearchResultList = dynamic(() => import('./SearchResultList').then(mod => mod.SearchResultList), { ssr: false });
 const SearchResultContent = dynamic(() => import('./SearchResultContent').then(mod => mod.SearchResultContent), {
   ssr: false,
   loading: () => <></>,

+ 0 - 1
apps/app/src/components/SearchPage/SearchResultList.tsx

@@ -18,7 +18,6 @@ import { mutateSearching } from '~/stores/search';
 import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import { PageListItemL } from '../PageList/PageListItemL';
 
-
 type Props = {
   pages: IPageWithSearchMeta[],
   selectedPageId?: string,

+ 7 - 7
apps/app/src/components/ShareLink/ShareLinkPageView.tsx → apps/app/src/components/ShareLinkPageView.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useMemo } from 'react';
+import React, { useMemo } from 'react';
 
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import dynamic from 'next/dynamic';
@@ -10,17 +10,17 @@ import { useIsNotFound } from '~/stores/page';
 import { useViewOptions } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';
 
-import { MainPane } from '../Layout/MainPane';
-import RevisionRenderer from '../Page/RevisionRenderer';
-import ShareLinkAlert from '../Page/ShareLinkAlert';
-import type { PageSideContentsProps } from '../PageSideContents';
+import { MainPane } from './Layout/MainPane';
+import RevisionRenderer from './Page/RevisionRenderer';
+import ShareLinkAlert from './Page/ShareLinkAlert';
+import type { PageSideContentsProps } from './PageSideContents';
 
 
 const logger = loggerFactory('growi:Page');
 
 
-const PageSideContents = dynamic<PageSideContentsProps>(() => import('../PageSideContents').then(mod => mod.PageSideContents), { ssr: false });
-const ForbiddenPage = dynamic(() => import('../ForbiddenPage'), { ssr: false });
+const PageSideContents = dynamic<PageSideContentsProps>(() => import('./PageSideContents').then(mod => mod.PageSideContents), { ssr: false });
+const ForbiddenPage = dynamic(() => import('./ForbiddenPage'), { ssr: false });
 
 
 type Props = {

+ 1 - 1
apps/app/src/components/Sidebar.tsx

@@ -297,7 +297,7 @@ const Sidebar = memo((): JSX.Element => {
   const isOpenClass = `${isDrawerOpened ? 'open' : ''}`;
   return (
     <>
-      <div className={`${grwSidebarClass} ${sidebarModeClass} ${isOpenClass} d-print-none`}>
+      <div className={`${grwSidebarClass} ${sidebarModeClass} ${isOpenClass} d-print-none`} data-testid="grw-sidebar">
         <div className="data-layout-container">
           <div
             className='navigation transition-enabled'

+ 3 - 7
apps/app/src/features/questionnaire/interfaces/growi-info.ts

@@ -1,5 +1,7 @@
 import * as os from 'node:os';
 
+import { IExternalAuthProviderType } from '@growi/core';
+
 export const GrowiServiceType = {
   cloud: 'cloud',
   privateCloud: 'private-cloud',
@@ -23,13 +25,7 @@ export const GrowiDeploymentType = {
   node: 'node',
   others: 'others',
 } as const;
-export const GrowiExternalAuthProviderType = {
-  ldap: 'ldap',
-  saml: 'saml',
-  oicd: 'oidc',
-  google: 'google',
-  github: 'github',
-} as const;
+export const GrowiExternalAuthProviderType = IExternalAuthProviderType;
 
 export type GrowiServiceType = typeof GrowiServiceType[keyof typeof GrowiServiceType]
 type GrowiWikiType = typeof GrowiWikiType[keyof typeof GrowiWikiType]

+ 0 - 11
apps/app/src/interfaces/external-account.ts

@@ -1,11 +0,0 @@
-import { Ref } from '@growi/core';
-
-import { IUser } from '~/interfaces/user';
-
-
-export type IExternalAccount<ID = string> = {
-  _id: ID,
-  providerType: string,
-  accountId: string,
-  user: Ref<IUser>,
-}

+ 1 - 13
apps/app/src/pages/[[...path]].page.tsx

@@ -83,7 +83,7 @@ const QuestionnaireModalManager = dynamic(() => import('~/features/questionnaire
 const logger = loggerFactory('growi:pages:all');
 
 const {
-  isPermalink: _isPermalink, isTrashPage: _isTrashPage, isCreatablePage,
+  isPermalink: _isPermalink, isCreatablePage,
 } = pagePathUtils;
 const { removeHeadingSlash } = pathUtils;
 
@@ -129,11 +129,6 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   );
 };
 
-const PutbackPageModal = (): JSX.Element => {
-  const PutbackPageModal = dynamic(() => import('../components/PutbackPageModal'), { ssr: false });
-  return <PutbackPageModal />;
-};
-
 type Props = CommonProps & {
   pageWithMeta: IPageToShowRevisionWithMeta | null,
   // pageUser?: any,
@@ -262,11 +257,6 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
 
   const growiLayoutFluidClass = useCurrentGrowiLayoutFluidClassName(pageWithMeta?.data);
 
-  const shouldRenderPutbackPageModal = pageWithMeta != null
-    ? _isTrashPage(pageWithMeta.data.path)
-    : false;
-
-
   // Store initial data (When revisionBody is not SSR)
   useEffect(() => {
     if (!props.skipSSR) {
@@ -365,8 +355,6 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
         />
 
         <PageStatusAlert />
-
-        {shouldRenderPutbackPageModal && <PutbackPageModal />}
       </div>
     </>
   );

+ 0 - 8
apps/app/src/pages/_search.page.tsx

@@ -3,7 +3,6 @@ import { ReactNode } from 'react';
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import { useTranslation } from 'next-i18next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
-import dynamic from 'next/dynamic';
 import Head from 'next/head';
 
 import SearchResultLayout from '~/components/Layout/SearchResultLayout';
@@ -66,11 +65,6 @@ const SearchResultPage: NextPageWithLayout<Props> = (props: Props) => {
   useShowPageLimitationL(props.showPageLimitationL);
   useIsContainerFluid(props.isContainerFluid);
 
-  const PutbackPageModal = (): JSX.Element => {
-    const PutbackPageModal = dynamic(() => import('../components/PutbackPageModal'), { ssr: false });
-    return <PutbackPageModal />;
-  };
-
   const title = generateCustomTitle(props, t('search_result.title'));
 
   return (
@@ -82,8 +76,6 @@ const SearchResultPage: NextPageWithLayout<Props> = (props: Props) => {
       <div id="search-page" className="dynamic-layout-root">
         <SearchPage />
       </div>
-
-      <PutbackPageModal />
     </>
   );
 };

+ 1 - 1
apps/app/src/pages/share/[[...path]].page.tsx

@@ -12,7 +12,7 @@ import { useCurrentGrowiLayoutFluidClassName } from '~/client/services/layout';
 import { ShareLinkLayout } from '~/components/Layout/ShareLinkLayout';
 import GrowiContextualSubNavigationSubstance from '~/components/Navbar/GrowiContextualSubNavigation';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
-import { ShareLinkPageView } from '~/components/ShareLink/ShareLinkPageView';
+import { ShareLinkPageView } from '~/components/ShareLinkPageView';
 import { SupportedAction, SupportedActionType } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';

+ 0 - 2
apps/app/src/pages/trash.page.tsx

@@ -27,7 +27,6 @@ import {
 
 const TrashPageList = dynamic(() => import('~/components/TrashPageList').then(mod => mod.TrashPageList), { ssr: false });
 const EmptyTrashModal = dynamic(() => import('~/components/EmptyTrashModal'), { ssr: false });
-const PutbackPageModal = dynamic(() => import('~/components/PutbackPageModal'), { ssr: false });
 
 type Props = CommonProps & {
   currentUser: IUser,
@@ -107,7 +106,6 @@ TrashPage.getLayout = function getLayout(page) {
         {page}
       </Layout>
       <EmptyTrashModal />
-      <PutbackPageModal />
     </>
   );
 };

+ 2 - 1
apps/app/src/server/routes/apiv3/index.js

@@ -9,6 +9,7 @@ import * as registerFormValidator from '../../middlewares/register-form-validato
 import g2gTransfer from './g2g-transfer';
 import importRoute from './import';
 import pageListing from './page-listing';
+import securitySettings from './security-settings';
 import * as userActivation from './user-activation';
 
 const logger = loggerFactory('growi:routes:apiv3'); // eslint-disable-line no-unused-vars
@@ -38,7 +39,7 @@ module.exports = (crowi, app) => {
   routerForAdmin.use('/export', require('./export')(crowi));
   routerForAdmin.use('/import', importRoute(crowi));
   routerForAdmin.use('/search', require('./search')(crowi));
-  routerForAdmin.use('/security-setting', require('./security-setting')(crowi));
+  routerForAdmin.use('/security-setting', securitySettings(crowi));
   routerForAdmin.use('/mongo', require('./mongo')(crowi));
   routerForAdmin.use('/slack-integration-settings', require('./slack-integration-settings')(crowi));
   routerForAdmin.use('/slack-integration-legacy-settings', require('./slack-integration-legacy-settings')(crowi));

+ 65 - 0
apps/app/src/server/routes/apiv3/security-settings/checkSetupStrategiesHasAdmin.ts

@@ -0,0 +1,65 @@
+import type { IExternalAuthProviderType } from '@growi/core';
+import mongoose from 'mongoose';
+
+interface AggregateResult {
+  count: number;
+}
+
+const checkLocalStrategyHasAdmin = async(): Promise<boolean> => {
+  const User = mongoose.model('User') as any;
+
+  const localAdmins: AggregateResult[] = await User.aggregate([
+    {
+      $match: {
+        admin: true,
+        status: User.STATUS_ACTIVE,
+        password: { $exists: true },
+      },
+    },
+    { $count: 'count' },
+  ]).exec();
+
+  return localAdmins.length > 0 && localAdmins[0].count > 0;
+};
+
+const checkExternalStrategiesHasAdmin = async(setupExternalStrategies: IExternalAuthProviderType[]): Promise<boolean> => {
+  const User = mongoose.model('User') as any;
+
+  const externalAdmins: AggregateResult[] = await User.aggregate([
+    { $match: { admin: true, status: User.STATUS_ACTIVE } },
+    {
+      $lookup: {
+        from: 'externalaccounts',
+        localField: '_id',
+        foreignField: 'user',
+        as: 'externalAccounts',
+      },
+    },
+    {
+      $match: {
+        'externalAccounts.providerType': { $in: setupExternalStrategies },
+      },
+    },
+    { $count: 'count' },
+  ]).exec();
+
+  return externalAdmins.length > 0 && externalAdmins[0].count > 0;
+};
+
+export const checkSetupStrategiesHasAdmin = async(setupStrategies: (IExternalAuthProviderType | 'local')[]): Promise<boolean> => {
+  if (setupStrategies.includes('local')) {
+    const isLocalStrategyHasAdmin = await checkLocalStrategyHasAdmin();
+    if (isLocalStrategyHasAdmin) {
+      return true;
+    }
+  }
+
+  const setupExternalStrategies = setupStrategies.filter(strategy => strategy !== 'local') as IExternalAuthProviderType[];
+  if (setupExternalStrategies.length === 0) {
+    return false;
+  }
+
+  const isExternalStrategiesHasAdmin = await checkExternalStrategiesHasAdmin(setupExternalStrategies);
+
+  return isExternalStrategiesHasAdmin;
+};

+ 158 - 148
apps/app/src/server/routes/apiv3/security-setting.js → apps/app/src/server/routes/apiv3/security-settings/index.js

@@ -2,12 +2,13 @@ import { ErrorV3 } from '@growi/core';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { PageDeleteConfigValue } from '~/interfaces/page-delete-config';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
-import { removeNullPropertyFromObject } from '~/utils/object-utils';
 import { validateDeleteConfigs, prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
-import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
-import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+import { checkSetupStrategiesHasAdmin } from './checkSetupStrategiesHasAdmin';
 
 
 const logger = loggerFactory('growi:routes:apiv3:security-setting');
@@ -309,14 +310,14 @@ const validator = {
  *            description: local account automatically linked the email matched
  */
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
   const activityEvent = crowi.event('activity');
 
   async function updateAndReloadStrategySettings(authId, params) {
-    const { configManager, passportService } = crowi;
+    const { passportService } = crowi;
 
     // update config without publishing S2sMessage
     await configManager.updateConfigsInTheSameNamespace('crowi', params, true);
@@ -348,100 +349,100 @@ module.exports = (crowi) => {
     const securityParams = {
       generalSetting: {
         restrictGuestMode: crowi.aclService.getGuestModeValue(),
-        pageDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageDeletionAuthority'),
-        pageCompleteDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority'),
-        pageRecursiveDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority'),
-        pageRecursiveCompleteDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
-        hideRestrictedByOwner: await crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
-        hideRestrictedByGroup: await crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
-        wikiMode: await crowi.configManager.getConfig('crowi', 'security:wikiMode'),
-        sessionMaxAge: await crowi.configManager.getConfig('crowi', 'security:sessionMaxAge'),
+        pageDeletionAuthority: await configManager.getConfig('crowi', 'security:pageDeletionAuthority'),
+        pageCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority'),
+        pageRecursiveDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority'),
+        pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
+        hideRestrictedByOwner: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
+        hideRestrictedByGroup: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
+        wikiMode: await configManager.getConfig('crowi', 'security:wikiMode'),
+        sessionMaxAge: await configManager.getConfig('crowi', 'security:sessionMaxAge'),
       },
       shareLinkSetting: {
-        disableLinkSharing: await crowi.configManager.getConfig('crowi', 'security:disableLinkSharing'),
+        disableLinkSharing: await configManager.getConfig('crowi', 'security:disableLinkSharing'),
       },
       localSetting: {
-        useOnlyEnvVarsForSomeOptions: await crowi.configManager.getConfig('crowi', 'security:passport-local:useOnlyEnvVarsForSomeOptions'),
-        registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
-        registrationWhitelist: await crowi.configManager.getConfig('crowi', 'security:registrationWhitelist'),
-        isPasswordResetEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
-        isEmailAuthenticationEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled'),
+        useOnlyEnvVarsForSomeOptions: await configManager.getConfig('crowi', 'security:passport-local:useOnlyEnvVarsForSomeOptions'),
+        registrationMode: await configManager.getConfig('crowi', 'security:registrationMode'),
+        registrationWhitelist: await configManager.getConfig('crowi', 'security:registrationWhitelist'),
+        isPasswordResetEnabled: await configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
+        isEmailAuthenticationEnabled: await configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled'),
       },
       generalAuth: {
-        isLocalEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isEnabled'),
-        isLdapEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:isEnabled'),
-        isSamlEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-saml:isEnabled'),
-        isOidcEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:isEnabled'),
-        isGoogleEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-google:isEnabled'),
-        isGitHubEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-github:isEnabled'),
+        isLocalEnabled: await configManager.getConfig('crowi', 'security:passport-local:isEnabled'),
+        isLdapEnabled: await configManager.getConfig('crowi', 'security:passport-ldap:isEnabled'),
+        isSamlEnabled: await configManager.getConfig('crowi', 'security:passport-saml:isEnabled'),
+        isOidcEnabled: await configManager.getConfig('crowi', 'security:passport-oidc:isEnabled'),
+        isGoogleEnabled: await configManager.getConfig('crowi', 'security:passport-google:isEnabled'),
+        isGitHubEnabled: await configManager.getConfig('crowi', 'security:passport-github:isEnabled'),
       },
       ldapAuth: {
-        serverUrl: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:serverUrl'),
-        isUserBind: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:isUserBind'),
-        ldapBindDN: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:bindDN'),
-        ldapBindDNPassword: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:bindDNPassword'),
-        ldapSearchFilter: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:searchFilter'),
-        ldapAttrMapUsername: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:attrMapUsername'),
-        isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:isSameUsernameTreatedAsIdenticalUser'),
-        ldapAttrMapMail: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:attrMapMail'),
-        ldapAttrMapName: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:attrMapName'),
-        ldapGroupSearchBase: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:groupSearchBase'),
-        ldapGroupSearchFilter: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:groupSearchFilter'),
-        ldapGroupDnProperty: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:groupDnProperty'),
+        serverUrl: await configManager.getConfig('crowi', 'security:passport-ldap:serverUrl'),
+        isUserBind: await configManager.getConfig('crowi', 'security:passport-ldap:isUserBind'),
+        ldapBindDN: await configManager.getConfig('crowi', 'security:passport-ldap:bindDN'),
+        ldapBindDNPassword: await configManager.getConfig('crowi', 'security:passport-ldap:bindDNPassword'),
+        ldapSearchFilter: await configManager.getConfig('crowi', 'security:passport-ldap:searchFilter'),
+        ldapAttrMapUsername: await configManager.getConfig('crowi', 'security:passport-ldap:attrMapUsername'),
+        isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-ldap:isSameUsernameTreatedAsIdenticalUser'),
+        ldapAttrMapMail: await configManager.getConfig('crowi', 'security:passport-ldap:attrMapMail'),
+        ldapAttrMapName: await configManager.getConfig('crowi', 'security:passport-ldap:attrMapName'),
+        ldapGroupSearchBase: await configManager.getConfig('crowi', 'security:passport-ldap:groupSearchBase'),
+        ldapGroupSearchFilter: await configManager.getConfig('crowi', 'security:passport-ldap:groupSearchFilter'),
+        ldapGroupDnProperty: await configManager.getConfig('crowi', 'security:passport-ldap:groupDnProperty'),
       },
       samlAuth: {
         missingMandatoryConfigKeys: await crowi.passportService.getSamlMissingMandatoryConfigKeys(),
-        useOnlyEnvVarsForSomeOptions: await crowi.configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:useOnlyEnvVarsForSomeOptions'),
-        samlEntryPoint: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:entryPoint'),
-        samlEnvVarEntryPoint: await crowi.configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:entryPoint'),
-        samlIssuer: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:issuer'),
-        samlEnvVarIssuer: await crowi.configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:issuer'),
-        samlCert: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:cert'),
-        samlEnvVarCert: await crowi.configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:cert'),
-        samlAttrMapId: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapId'),
-        samlEnvVarAttrMapId: await crowi.configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapId'),
-        samlAttrMapUsername: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapUsername'),
-        samlEnvVarAttrMapUsername: await crowi.configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapUsername'),
-        samlAttrMapMail: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapMail'),
-        samlEnvVarAttrMapMail: await crowi.configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapMail'),
-        samlAttrMapFirstName: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapFirstName'),
-        samlEnvVarAttrMapFirstName: await crowi.configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapFirstName'),
-        samlAttrMapLastName: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapLastName'),
-        samlEnvVarAttrMapLastName: await crowi.configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapLastName'),
-        isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-saml:isSameUsernameTreatedAsIdenticalUser'),
-        isSameEmailTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-saml:isSameEmailTreatedAsIdenticalUser'),
-        samlABLCRule: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:ABLCRule'),
-        samlEnvVarABLCRule: await crowi.configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:ABLCRule'),
+        useOnlyEnvVarsForSomeOptions: await configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:useOnlyEnvVarsForSomeOptions'),
+        samlEntryPoint: await configManager.getConfigFromDB('crowi', 'security:passport-saml:entryPoint'),
+        samlEnvVarEntryPoint: await configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:entryPoint'),
+        samlIssuer: await configManager.getConfigFromDB('crowi', 'security:passport-saml:issuer'),
+        samlEnvVarIssuer: await configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:issuer'),
+        samlCert: await configManager.getConfigFromDB('crowi', 'security:passport-saml:cert'),
+        samlEnvVarCert: await configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:cert'),
+        samlAttrMapId: await configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapId'),
+        samlEnvVarAttrMapId: await configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapId'),
+        samlAttrMapUsername: await configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapUsername'),
+        samlEnvVarAttrMapUsername: await configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapUsername'),
+        samlAttrMapMail: await configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapMail'),
+        samlEnvVarAttrMapMail: await configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapMail'),
+        samlAttrMapFirstName: await configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapFirstName'),
+        samlEnvVarAttrMapFirstName: await configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapFirstName'),
+        samlAttrMapLastName: await configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapLastName'),
+        samlEnvVarAttrMapLastName: await configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapLastName'),
+        isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-saml:isSameUsernameTreatedAsIdenticalUser'),
+        isSameEmailTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-saml:isSameEmailTreatedAsIdenticalUser'),
+        samlABLCRule: await configManager.getConfigFromDB('crowi', 'security:passport-saml:ABLCRule'),
+        samlEnvVarABLCRule: await configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:ABLCRule'),
       },
       oidcAuth: {
-        oidcProviderName: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:providerName'),
-        oidcIssuerHost: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:issuerHost'),
-        oidcAuthorizationEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:authorizationEndpoint'),
-        oidcTokenEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:tokenEndpoint'),
-        oidcRevocationEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:revocationEndpoint'),
-        oidcIntrospectionEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:introspectionEndpoint'),
-        oidcUserInfoEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:userInfoEndpoint'),
-        oidcEndSessionEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:endSessionEndpoint'),
-        oidcRegistrationEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:registrationEndpoint'),
-        oidcJWKSUri: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:jwksUri'),
-        oidcClientId: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:clientId'),
-        oidcClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:clientSecret'),
-        oidcAttrMapId: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapId'),
-        oidcAttrMapUserName: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapUserName'),
-        oidcAttrMapName: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapName'),
-        oidcAttrMapEmail: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapMail'),
-        isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:isSameUsernameTreatedAsIdenticalUser'),
-        isSameEmailTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:isSameEmailTreatedAsIdenticalUser'),
+        oidcProviderName: await configManager.getConfig('crowi', 'security:passport-oidc:providerName'),
+        oidcIssuerHost: await configManager.getConfig('crowi', 'security:passport-oidc:issuerHost'),
+        oidcAuthorizationEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:authorizationEndpoint'),
+        oidcTokenEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:tokenEndpoint'),
+        oidcRevocationEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:revocationEndpoint'),
+        oidcIntrospectionEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:introspectionEndpoint'),
+        oidcUserInfoEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:userInfoEndpoint'),
+        oidcEndSessionEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:endSessionEndpoint'),
+        oidcRegistrationEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:registrationEndpoint'),
+        oidcJWKSUri: await configManager.getConfig('crowi', 'security:passport-oidc:jwksUri'),
+        oidcClientId: await configManager.getConfig('crowi', 'security:passport-oidc:clientId'),
+        oidcClientSecret: await configManager.getConfig('crowi', 'security:passport-oidc:clientSecret'),
+        oidcAttrMapId: await configManager.getConfig('crowi', 'security:passport-oidc:attrMapId'),
+        oidcAttrMapUserName: await configManager.getConfig('crowi', 'security:passport-oidc:attrMapUserName'),
+        oidcAttrMapName: await configManager.getConfig('crowi', 'security:passport-oidc:attrMapName'),
+        oidcAttrMapEmail: await configManager.getConfig('crowi', 'security:passport-oidc:attrMapMail'),
+        isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-oidc:isSameUsernameTreatedAsIdenticalUser'),
+        isSameEmailTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-oidc:isSameEmailTreatedAsIdenticalUser'),
       },
       googleOAuth: {
-        googleClientId: await crowi.configManager.getConfig('crowi', 'security:passport-google:clientId'),
-        googleClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-google:clientSecret'),
-        isSameEmailTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-google:isSameEmailTreatedAsIdenticalUser'),
+        googleClientId: await configManager.getConfig('crowi', 'security:passport-google:clientId'),
+        googleClientSecret: await configManager.getConfig('crowi', 'security:passport-google:clientSecret'),
+        isSameEmailTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-google:isSameEmailTreatedAsIdenticalUser'),
       },
       githubOAuth: {
-        githubClientId: await crowi.configManager.getConfig('crowi', 'security:passport-github:clientId'),
-        githubClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-github:clientSecret'),
-        isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-github:isSameUsernameTreatedAsIdenticalUser'),
+        githubClientId: await configManager.getConfig('crowi', 'security:passport-github:clientId'),
+        githubClientSecret: await configManager.getConfig('crowi', 'security:passport-github:clientSecret'),
+        isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-github:isSameUsernameTreatedAsIdenticalUser'),
       },
     };
     return res.apiv3({ securityParams });
@@ -489,13 +490,22 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3('Can not turn everything off'), 405);
     }
 
+    if (!isEnabled) {
+      const isSetupStrategiesHasAdmin = await checkSetupStrategiesHasAdmin(setupStrategies);
+
+      // Return an error when disabling an strategy when there are no setup strategies with admin-enabled login
+      if (!isSetupStrategiesHasAdmin) {
+        return res.apiv3Err(new ErrorV3('Must have admin enabled authentication method'), 405);
+      }
+    }
+
     const enableParams = { [`security:passport-${authId}:isEnabled`]: isEnabled };
 
     try {
       await updateAndReloadStrategySettings(authId, enableParams);
 
       const responseParams = {
-        [`security:passport-${authId}:isEnabled`]: await crowi.configManager.getConfig('crowi', `security:passport-${authId}:isEnabled`),
+        [`security:passport-${authId}:isEnabled`]: await configManager.getConfig('crowi', `security:passport-${authId}:isEnabled`),
       };
       switch (authId) {
         case 'local':
@@ -624,22 +634,22 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3('Delete config values are not correct.', 'delete_config_not_normalized'));
     }
 
-    const wikiMode = await crowi.configManager.getConfig('crowi', 'security:wikiMode');
+    const wikiMode = await configManager.getConfig('crowi', 'security:wikiMode');
     if (wikiMode === 'private' || wikiMode === 'public') {
       logger.debug('security:restrictGuestMode will not be changed because wiki mode is forced to set');
       delete updateData['security:restrictGuestMode'];
     }
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', updateData);
+      await configManager.updateConfigsInTheSameNamespace('crowi', updateData);
       const securitySettingParams = {
-        sessionMaxAge: await crowi.configManager.getConfig('crowi', 'security:sessionMaxAge'),
-        restrictGuestMode: await crowi.configManager.getConfig('crowi', 'security:restrictGuestMode'),
-        pageDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageDeletionAuthority'),
-        pageCompleteDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority'),
-        pageRecursiveDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority'),
-        pageRecursiveCompleteDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
-        hideRestrictedByOwner: await crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
-        hideRestrictedByGroup: await crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
+        sessionMaxAge: await configManager.getConfig('crowi', 'security:sessionMaxAge'),
+        restrictGuestMode: await configManager.getConfig('crowi', 'security:restrictGuestMode'),
+        pageDeletionAuthority: await configManager.getConfig('crowi', 'security:pageDeletionAuthority'),
+        pageCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority'),
+        pageRecursiveDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority'),
+        pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
+        hideRestrictedByOwner: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
+        hideRestrictedByGroup: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
       };
 
       const parameters = { action: SupportedAction.ACTION_ADMIN_SECURITY_SETTINGS_UPDATE };
@@ -680,9 +690,9 @@ module.exports = (crowi) => {
       'security:disableLinkSharing': req.body.disableLinkSharing,
     };
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', updateData);
+      await configManager.updateConfigsInTheSameNamespace('crowi', updateData);
       const securitySettingParams = {
-        disableLinkSharing: crowi.configManager.getConfig('crowi', 'security:disableLinkSharing'),
+        disableLinkSharing: configManager.getConfig('crowi', 'security:disableLinkSharing'),
       };
       // eslint-disable-next-line max-len
       const parameters = { action: updateData['security:disableLinkSharing'] ? SupportedAction.ACTION_ADMIN_REJECT_SHARE_LINK : SupportedAction.ACTION_ADMIN_PERMIT_SHARE_LINK };
@@ -799,10 +809,10 @@ module.exports = (crowi) => {
       await updateAndReloadStrategySettings('local', requestParams);
 
       const localSettingParams = {
-        registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
-        registrationWhitelist: await crowi.configManager.getConfig('crowi', 'security:registrationWhitelist'),
-        isPasswordResetEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
-        isEmailAuthenticationEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled'),
+        registrationMode: await configManager.getConfig('crowi', 'security:registrationMode'),
+        registrationWhitelist: await configManager.getConfig('crowi', 'security:registrationWhitelist'),
+        isPasswordResetEnabled: await configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
+        isEmailAuthenticationEnabled: await configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled'),
       };
       const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_ID_PASS_UPDATE };
       activityEvent.emit('update', res.locals.activity._id, parameters);
@@ -856,18 +866,18 @@ module.exports = (crowi) => {
       await updateAndReloadStrategySettings('ldap', requestParams);
 
       const securitySettingParams = {
-        serverUrl: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:serverUrl'),
-        isUserBind: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:isUserBind'),
-        ldapBindDN: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:bindDN'),
-        ldapBindDNPassword: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:bindDNPassword'),
-        ldapSearchFilter: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:searchFilter'),
-        ldapAttrMapUsername: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:attrMapUsername'),
-        isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:isSameUsernameTreatedAsIdenticalUser'),
-        ldapAttrMapMail: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:attrMapMail'),
-        ldapAttrMapName: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:attrMapName'),
-        ldapGroupSearchBase: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:groupSearchBase'),
-        ldapGroupSearchFilter: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:groupSearchFilter'),
-        ldapGroupDnProperty: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:groupDnProperty'),
+        serverUrl: await configManager.getConfig('crowi', 'security:passport-ldap:serverUrl'),
+        isUserBind: await configManager.getConfig('crowi', 'security:passport-ldap:isUserBind'),
+        ldapBindDN: await configManager.getConfig('crowi', 'security:passport-ldap:bindDN'),
+        ldapBindDNPassword: await configManager.getConfig('crowi', 'security:passport-ldap:bindDNPassword'),
+        ldapSearchFilter: await configManager.getConfig('crowi', 'security:passport-ldap:searchFilter'),
+        ldapAttrMapUsername: await configManager.getConfig('crowi', 'security:passport-ldap:attrMapUsername'),
+        isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-ldap:isSameUsernameTreatedAsIdenticalUser'),
+        ldapAttrMapMail: await configManager.getConfig('crowi', 'security:passport-ldap:attrMapMail'),
+        ldapAttrMapName: await configManager.getConfig('crowi', 'security:passport-ldap:attrMapName'),
+        ldapGroupSearchBase: await configManager.getConfig('crowi', 'security:passport-ldap:groupSearchBase'),
+        ldapGroupSearchFilter: await configManager.getConfig('crowi', 'security:passport-ldap:groupSearchFilter'),
+        ldapGroupDnProperty: await configManager.getConfig('crowi', 'security:passport-ldap:groupDnProperty'),
       };
       const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_LDAP_UPDATE };
       activityEvent.emit('update', res.locals.activity._id, parameters);
@@ -910,7 +920,7 @@ module.exports = (crowi) => {
     for (const configKey of crowi.passportService.mandatoryConfigKeysForSaml) {
       const key = configKey.replace('security:passport-saml:', '');
       const formValue = req.body[key];
-      if (crowi.configManager.getConfigFromEnvVars('crowi', configKey) === null && formValue == null) {
+      if (configManager.getConfigFromEnvVars('crowi', configKey) === null && formValue == null) {
         const formItemName = req.t(`security_setting.form_item_name.${key}`);
         invalidValues.push(req.t('form_validation.required', formItemName));
       }
@@ -950,17 +960,17 @@ module.exports = (crowi) => {
 
       const securitySettingParams = {
         missingMandatoryConfigKeys: await crowi.passportService.getSamlMissingMandatoryConfigKeys(),
-        samlEntryPoint: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:entryPoint'),
-        samlIssuer: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:issuer'),
-        samlCert: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:cert'),
-        samlAttrMapId: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapId'),
-        samlAttrMapUsername: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapUsername'),
-        samlAttrMapMail: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapMail'),
-        samlAttrMapFirstName: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapFirstName'),
-        samlAttrMapLastName: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapLastName'),
-        isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-saml:isSameUsernameTreatedAsIdenticalUser'),
-        isSameEmailTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-saml:isSameEmailTreatedAsIdenticalUser'),
-        samlABLCRule: await crowi.configManager.getConfig('crowi', 'security:passport-saml:ABLCRule'),
+        samlEntryPoint: await configManager.getConfigFromDB('crowi', 'security:passport-saml:entryPoint'),
+        samlIssuer: await configManager.getConfigFromDB('crowi', 'security:passport-saml:issuer'),
+        samlCert: await configManager.getConfigFromDB('crowi', 'security:passport-saml:cert'),
+        samlAttrMapId: await configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapId'),
+        samlAttrMapUsername: await configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapUsername'),
+        samlAttrMapMail: await configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapMail'),
+        samlAttrMapFirstName: await configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapFirstName'),
+        samlAttrMapLastName: await configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapLastName'),
+        isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-saml:isSameUsernameTreatedAsIdenticalUser'),
+        isSameEmailTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-saml:isSameEmailTreatedAsIdenticalUser'),
+        samlABLCRule: await configManager.getConfig('crowi', 'security:passport-saml:ABLCRule'),
       };
       const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_SAML_UPDATE };
       activityEvent.emit('update', res.locals.activity._id, parameters);
@@ -1020,24 +1030,24 @@ module.exports = (crowi) => {
       await updateAndReloadStrategySettings('oidc', requestParams);
 
       const securitySettingParams = {
-        oidcProviderName: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:providerName'),
-        oidcIssuerHost: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:issuerHost'),
-        oidcAuthorizationEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:authorizationEndpoint'),
-        oidcTokenEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:tokenEndpoint'),
-        oidcRevocationEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:revocationEndpoint'),
-        oidcIntrospectionEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:introspectionEndpoint'),
-        oidcUserInfoEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:userInfoEndpoint'),
-        oidcEndSessionEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:endSessionEndpoint'),
-        oidcRegistrationEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:registrationEndpoint'),
-        oidcJWKSUri: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:jwksUri'),
-        oidcClientId: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:clientId'),
-        oidcClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:clientSecret'),
-        oidcAttrMapId: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapId'),
-        oidcAttrMapUserName: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapUserName'),
-        oidcAttrMapName: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapName'),
-        oidcAttrMapEmail: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapMail'),
-        isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:isSameUsernameTreatedAsIdenticalUser'),
-        isSameEmailTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:isSameEmailTreatedAsIdenticalUser'),
+        oidcProviderName: await configManager.getConfig('crowi', 'security:passport-oidc:providerName'),
+        oidcIssuerHost: await configManager.getConfig('crowi', 'security:passport-oidc:issuerHost'),
+        oidcAuthorizationEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:authorizationEndpoint'),
+        oidcTokenEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:tokenEndpoint'),
+        oidcRevocationEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:revocationEndpoint'),
+        oidcIntrospectionEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:introspectionEndpoint'),
+        oidcUserInfoEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:userInfoEndpoint'),
+        oidcEndSessionEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:endSessionEndpoint'),
+        oidcRegistrationEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:registrationEndpoint'),
+        oidcJWKSUri: await configManager.getConfig('crowi', 'security:passport-oidc:jwksUri'),
+        oidcClientId: await configManager.getConfig('crowi', 'security:passport-oidc:clientId'),
+        oidcClientSecret: await configManager.getConfig('crowi', 'security:passport-oidc:clientSecret'),
+        oidcAttrMapId: await configManager.getConfig('crowi', 'security:passport-oidc:attrMapId'),
+        oidcAttrMapUserName: await configManager.getConfig('crowi', 'security:passport-oidc:attrMapUserName'),
+        oidcAttrMapName: await configManager.getConfig('crowi', 'security:passport-oidc:attrMapName'),
+        oidcAttrMapEmail: await configManager.getConfig('crowi', 'security:passport-oidc:attrMapMail'),
+        isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-oidc:isSameUsernameTreatedAsIdenticalUser'),
+        isSameEmailTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-oidc:isSameEmailTreatedAsIdenticalUser'),
       };
       const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_OIDC_UPDATE };
       activityEvent.emit('update', res.locals.activity._id, parameters);
@@ -1083,9 +1093,9 @@ module.exports = (crowi) => {
       await updateAndReloadStrategySettings('google', requestParams);
 
       const securitySettingParams = {
-        googleClientId: await crowi.configManager.getConfig('crowi', 'security:passport-google:clientId'),
-        googleClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-google:clientSecret'),
-        isSameEmailTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-google:isSameEmailTreatedAsIdenticalUser'),
+        googleClientId: await configManager.getConfig('crowi', 'security:passport-google:clientId'),
+        googleClientSecret: await configManager.getConfig('crowi', 'security:passport-google:clientSecret'),
+        isSameEmailTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-google:isSameEmailTreatedAsIdenticalUser'),
       };
       const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_GOOGLE_UPDATE };
       activityEvent.emit('update', res.locals.activity._id, parameters);
@@ -1130,9 +1140,9 @@ module.exports = (crowi) => {
       await updateAndReloadStrategySettings('github', requestParams);
 
       const securitySettingParams = {
-        githubClientId: await crowi.configManager.getConfig('crowi', 'security:passport-github:clientId'),
-        githubClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-github:clientSecret'),
-        isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-github:isSameUsernameTreatedAsIdenticalUser'),
+        githubClientId: await configManager.getConfig('crowi', 'security:passport-github:clientId'),
+        githubClientSecret: await configManager.getConfig('crowi', 'security:passport-github:clientSecret'),
+        isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-github:isSameUsernameTreatedAsIdenticalUser'),
       };
       const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_GITHUB_UPDATE };
       activityEvent.emit('update', res.locals.activity._id, parameters);

+ 13 - 4
apps/app/src/stores/modal.tsx

@@ -357,6 +357,7 @@ type PageAccessoriesModalStatus = {
 type PageAccessoriesModalUtils = {
   open(activatedContents: PageAccessoriesModalContents): void
   close(): void
+  selectContents(activatedContents: PageAccessoriesModalContents): void
 }
 
 export const usePageAccessoriesModal = (): SWRResponse<PageAccessoriesModalStatus, Error> & PageAccessoriesModalUtils => {
@@ -364,9 +365,8 @@ export const usePageAccessoriesModal = (): SWRResponse<PageAccessoriesModalStatu
   const initialStatus = { isOpened: false };
   const swrResponse = useStaticSWR<PageAccessoriesModalStatus, Error>('pageAccessoriesModalStatus', undefined, { fallbackData: initialStatus });
 
-  return {
-    ...swrResponse,
-    open: (activatedContents: PageAccessoriesModalContents) => {
+  return Object.assign(swrResponse, {
+    open: (activatedContents) => {
       if (swrResponse.data == null) {
         return;
       }
@@ -381,7 +381,16 @@ export const usePageAccessoriesModal = (): SWRResponse<PageAccessoriesModalStatu
       }
       swrResponse.mutate({ isOpened: false });
     },
-  };
+    selectContents: (activatedContents) => {
+      if (swrResponse.data == null) {
+        return;
+      }
+      swrResponse.mutate({
+        isOpened: swrResponse.data.isOpened,
+        activatedContents,
+      });
+    },
+  });
 };
 
 /*

+ 3 - 4
apps/app/src/stores/personal-settings.tsx

@@ -1,8 +1,7 @@
-import { ErrorV3 } from '@growi/core';
+import type { IExternalAccountHasId, IExternalAuthProviderType } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import useSWR, { SWRConfiguration, SWRResponse } from 'swr';
 
-import { IExternalAccount } from '~/interfaces/external-account';
 import { IUser } from '~/interfaces/user';
 import { useIsGuestUser } from '~/stores/context';
 import loggerFactory from '~/utils/logger';
@@ -31,7 +30,7 @@ export type IPersonalSettingsInfoOption = {
   sync: () => void,
   updateBasicInfo: () => Promise<void>,
   associateLdapAccount: (account: { username: string, password: string }) => Promise<void>,
-  disassociateLdapAccount: (account: { providerType: string, accountId: string }) => Promise<void>,
+  disassociateLdapAccount: (account: { providerType: IExternalAuthProviderType, accountId: string }) => Promise<void>,
 }
 
 export const usePersonalSettings = (config?: SWRConfiguration): SWRResponse<IUser, Error> & IPersonalSettingsInfoOption => {
@@ -104,7 +103,7 @@ export const usePersonalSettings = (config?: SWRConfiguration): SWRResponse<IUse
   };
 };
 
-export const useSWRxPersonalExternalAccounts = (): SWRResponse<IExternalAccount[], Error> => {
+export const useSWRxPersonalExternalAccounts = (): SWRResponse<IExternalAccountHasId[], Error> => {
   return useSWR(
     '/personal-setting/external-accounts',
     endpoint => apiv3Get(endpoint).then(response => response.data.externalAccounts),

+ 51 - 33
apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-page.cy.ts

@@ -7,10 +7,25 @@ function openEditor() {
     });
     // until
     return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
-  })
+  });
   cy.get('.CodeMirror').should('be.visible');
 }
 
+function appendTextToEditorUntilContains(inputText: string) {
+  const lines: string[] = [];
+  cy.waitUntil(() => {
+    // do
+    cy.get('.CodeMirror textarea').type(inputText, { force: true });
+    // until
+    return cy.get('.CodeMirror-line')
+      .each(($item) => {
+        lines.push($item.text());
+      }).then(() => {
+        return lines.join('\n').endsWith(inputText);
+      });
+  });
+}
+
 context('Access to page', () => {
   const ssPrefix = 'access-to-page-';
 
@@ -23,20 +38,20 @@ context('Access to page', () => {
 
   it('/Sandbox is successfully loaded', () => {
     cy.visit('/Sandbox');
-    cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true, true);
 
     // for check download toc data
     // https://redmine.weseek.co.jp/issues/111384
     // cy.get('.toc-link').should('be.visible');
 
-    cy.collapseSidebar(true, true);
+    cy.waitUntilSkeletonDisappear();
     cy.screenshot(`${ssPrefix}-sandbox`);
   });
 
   // TODO: https://redmine.weseek.co.jp/issues/109939
   it('/Sandbox with anchor hash is successfully loaded', () => {
     cy.visit('/Sandbox#headers');
-    cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true);
 
     // for check download toc data
     // https://redmine.weseek.co.jp/issues/111384
@@ -45,18 +60,21 @@ context('Access to page', () => {
     // hide fab
     cy.getByTestid('grw-fab-container').invoke('attr', 'style', 'display: none');
 
+    // assert the element is in viewport
+    cy.get('#headers').should('be.inViewport');
+
     // remove animation for screenshot
     // remove 'blink' class because ::after element cannot be operated
     // https://stackoverflow.com/questions/5041494/selecting-and-manipulating-css-pseudo-elements-such-as-before-and-after-usin/21709814#21709814
     cy.get('#headers').invoke('removeClass', 'blink');
 
-    cy.collapseSidebar(true);
+    cy.waitUntilSkeletonDisappear();
     cy.screenshot(`${ssPrefix}-sandbox-headers`);
   });
 
   it('/Sandbox/Math is successfully loaded', () => {
     cy.visit('/Sandbox/Math');
-    cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true);
 
     // for check download toc data
     // https://redmine.weseek.co.jp/issues/111384
@@ -64,69 +82,67 @@ context('Access to page', () => {
 
     cy.get('.math').should('be.visible');
 
-    cy.collapseSidebar(true);
+    cy.waitUntilSkeletonDisappear();
     cy.screenshot(`${ssPrefix}-sandbox-math`);
   });
 
   it('/Sandbox with edit is successfully loaded', () => {
     cy.visit('/Sandbox#edit');
-    cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true);
 
     cy.getByTestid('navbar-editor').should('be.visible');
     cy.get('.grw-editor-navbar-bottom').should('be.visible');
     cy.getByTestid('save-page-btn').should('be.visible');
     cy.get('.grw-grant-selector').should('be.visible');
 
-    cy.collapseSidebar(true);
+    cy.waitUntilSkeletonDisappear();
     cy.screenshot(`${ssPrefix}-Sandbox-edit-page`);
   })
 
   const body1 = 'hello';
   const body2 = ' world!';
-  it('View and Edit contents are successfully loaded', () => {
+  it('Edit and save with save-page-btn', () => {
     cy.visit('/Sandbox/testForUseEditingMarkdown');
 
     openEditor();
 
     // check edited contents after save
-    cy.get('.CodeMirror').type(body1);
-    cy.get('.CodeMirror').contains(body1);
-    cy.get('.page-editor-preview-body').contains(body1);
+    appendTextToEditorUntilContains(body1);
+    cy.get('.page-editor-preview-body').should('contain.text', body1);
     cy.getByTestid('page-editor').should('be.visible');
     cy.getByTestid('save-page-btn').click();
     cy.get('.wiki').should('be.visible');
     cy.get('.wiki').children().first().should('have.text', body1);
+    cy.screenshot(`${ssPrefix}-edit-and-save-with-save-page-btn`);
   })
 
-  it('Editing contents are successfully loaded with shortcut key', () => {
+  it('Edit and save with shortcut key', () => {
     const savePageShortcutKey = '{ctrl+s}';
 
     cy.visit('/Sandbox/testForUseEditingMarkdown');
 
     openEditor();
 
-    cy.get('.CodeMirror').contains(body1);
-
     // check editing contents with shortcut key
-    cy.get('.CodeMirror').type(body2);
-    cy.get('.CodeMirror').contains(body1+body2);
-    cy.get('.page-editor-preview-body').contains(body1+body2);
-    cy.get('.CodeMirror').type(savePageShortcutKey);
-    cy.get('.CodeMirror').contains(body1+body2);
-    cy.get('.page-editor-preview-body').contains(body1+body2);
+    appendTextToEditorUntilContains(body2);
+    cy.get('.page-editor-preview-body').should('contain.text', body1+body2);
+    cy.get('.CodeMirror').click().type(savePageShortcutKey);
+    cy.get('.CodeMirror-code').should('contain.text', body1+body2);
+    cy.get('.page-editor-preview-body').should('contain.text', body1+body2);
+    cy.screenshot(`${ssPrefix}-edit-and-save-with-shortcut-key`);
   })
 
   it('/user/admin is successfully loaded', () => {
     cy.visit('/user/admin');
+    cy.collapseSidebar(true);
 
-    cy.waitUntilSkeletonDisappear();
     // for check download toc data
     // https://redmine.weseek.co.jp/issues/111384
     // cy.get('.toc-link').should('be.visible');
 
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(2000); // wait for calcViewHeight and rendering
-    cy.collapseSidebar(true);
+    cy.waitUntilSkeletonDisappear();
     cy.screenshot(`${ssPrefix}-user-admin`);
   });
 
@@ -172,7 +188,7 @@ context('Access to special pages', () => {
   it('/trash is successfully loaded', () => {
     cy.visit('/trash');
 
-    cy.getByTestid('trash-page-list').contains('There are no pages under this page.');
+    cy.getByTestid('trash-page-list').should('contain.text', 'There are no pages under this page.');
 
     cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-trash`);
@@ -191,7 +207,7 @@ context('Access to special pages', () => {
 
     cy.getByTestid('tags-page').within(() => {
       cy.getByTestid('grw-tags-list').should('be.visible');
-      cy.getByTestid('grw-tags-list').contains('You have no tag, You can set tags on pages');
+      cy.getByTestid('grw-tags-list').should('contain.text', 'You have no tag, You can set tags on pages');
     });
 
     cy.collapseSidebar(true);
@@ -233,9 +249,11 @@ context('Access to Template Editing Mode', () => {
     })
 
     cy.visit(`/${parentPagePath}/${newPagePath}`);
-    cy.waitUntilSkeletonDisappear();
     cy.collapseSidebar(true);
 
+    cy.getByTestid('grw-contextual-sub-nav').should('be.visible');
+    cy.waitUntilSkeletonDisappear();
+
     // Check if the template is applied
     cy.get('.content-main').within(() => {
       cy.get('.wiki').should('be.visible');
@@ -277,9 +295,9 @@ context('Access to Template Editing Mode', () => {
       cy.screenshot(`${ssPrefix}-open-template-page-for-children-in-editor-mode`);
     });
 
-    cy.get('.CodeMirror').type(templateBody1);
-    cy.get('.CodeMirror').contains(templateBody1);
-    cy.get('.page-editor-preview-body').contains(templateBody1);
+    cy.get('.CodeMirror textarea').type(templateBody1, { force: true });
+    cy.get('.CodeMirror-code').should('contain.text', templateBody1);
+    cy.get('.page-editor-preview-body').should('contain.text', templateBody1);
     cy.getByTestid('page-editor').should('be.visible');
     cy.getByTestid('save-page-btn').click();
   });
@@ -312,9 +330,9 @@ context('Access to Template Editing Mode', () => {
       cy.screenshot(`${ssPrefix}-open-template-page-for-descendants-in-editor-mode`);
     })
 
-    cy.get('.CodeMirror').type(templateBody2);
-    cy.get('.CodeMirror').contains(templateBody2);
-    cy.get('.page-editor-preview-body').contains(templateBody2);
+    cy.get('.CodeMirror textarea').type(templateBody2, { force: true });
+    cy.get('.CodeMirror-code').should('contain.text', templateBody2);
+    cy.get('.page-editor-preview-body').should('contain.text', templateBody2);
     cy.getByTestid('page-editor').should('be.visible');
     cy.getByTestid('save-page-btn').click();
   });

+ 2 - 4
apps/app/test/cypress/e2e/20-basic-features/20-basic-features--use-tools.cy.ts

@@ -120,6 +120,7 @@ context('Page Accessories Modal', () => {
     });
 
     cy.visit('/');
+    cy.collapseSidebar(true, true);
 
     cy.waitUntil(() => {
       // do
@@ -141,7 +142,6 @@ context('Page Accessories Modal', () => {
 
     cy.getByTestid('page-history').should('be.visible');
 
-    cy.collapseSidebar(true, true);
     cy.waitUntilSpinnerDisappear();
     cy.screenshot(`${ssPrefix}-open-page-history-bootstrap4`);
   });
@@ -154,7 +154,6 @@ context('Page Accessories Modal', () => {
     cy.waitUntilSpinnerDisappear();
     cy.getByTestid('page-attachment').should('be.visible').contains('No attachments yet.');
 
-    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-open-page-attachment-data-bootstrap4`);
   });
 
@@ -167,7 +166,6 @@ context('Page Accessories Modal', () => {
     cy.getByTestid('page-accessories-modal').should('be.visible');
     cy.getByTestid('share-link-management').should('be.visible');
 
-    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-open-share-link-management-bootstrap4`);
   });
 });
@@ -186,6 +184,7 @@ context('Tag Oprations', { scrollBehavior: false }, () =>{
     const tag = 'we';
 
     cy.visit('/Sandbox/Bootstrap4');
+    cy.collapseSidebar(true);
 
     // Add tag
     cy.get('#edit-tags-btn-wrapper-for-tooltip').as('edit-tag-tooltip').should('be.visible');
@@ -198,7 +197,6 @@ context('Tag Oprations', { scrollBehavior: false }, () =>{
       return cy.get('#edit-tag-modal').then($elem => $elem.is(':visible'));
     });
 
-    cy.collapseSidebar(true);
     cy.get('#edit-tag-modal').should('be.visible').screenshot(`${ssPrefix}1-edit-tag-input`);
 
     cy.get('#edit-tag-modal').should('be.visible').within(() => {

+ 10 - 6
apps/app/test/cypress/e2e/21-basic-features-for-guest/21-basic-features-for-guest--access-to-page.cy.ts

@@ -11,23 +11,27 @@ context('Access to page by guest', () => {
 
   // TODO: https://redmine.weseek.co.jp/issues/109939
   it('/Sandbox with anchor hash is successfully loaded', () => {
-    cy.visit('/Sandbox#Headers');
-    cy.waitUntilSkeletonDisappear();
+    cy.visit('/Sandbox#headers');
+    cy.collapseSidebar(true);
 
     // hide fab
     cy.getByTestid('grw-fab-container').invoke('attr', 'style', 'display: none');
 
+    // assert the element is in viewport
+    cy.get('#headers').should('be.inViewport');
+
     // remove animation for screenshot
     // remove 'blink' class because ::after element cannot be operated
     // https://stackoverflow.com/questions/5041494/selecting-and-manipulating-css-pseudo-elements-such-as-before-and-after-usin/21709814#21709814
     cy.get('#headers').invoke('removeClass', 'blink');
 
+    cy.waitUntilSkeletonDisappear();
     cy.screenshot(`${ssPrefix}-sandbox-headers`);
   });
 
   it('/Sandbox/Math is successfully loaded', () => {
     cy.visit('/Sandbox/Math');
-    cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true);
 
     // for check download toc data
     // https://redmine.weseek.co.jp/issues/111384
@@ -35,15 +39,15 @@ context('Access to page by guest', () => {
 
     cy.get('.math').should('be.visible');
 
-    cy.collapseSidebar(true);
+    cy.waitUntilSkeletonDisappear();
     cy.screenshot(`${ssPrefix}-sandbox-math`);
   });
 
   it('/Sandbox with edit is successfully loaded', () => {
     cy.visit('/Sandbox#edit');
-    cy.waitUntilSkeletonDisappear();
-
     cy.collapseSidebar(true);
+
+    cy.waitUntilSkeletonDisappear();
     cy.screenshot(`${ssPrefix}-sandbox-with-edit-hash`);
   })
 

+ 3 - 5
apps/app/test/cypress/e2e/22-sharelink/22-sharelink--access-to-sharelink.cy.ts

@@ -14,13 +14,11 @@ context('Access to sharelink by guest', () => {
     // open dropdown
     cy.waitUntil(() => {
       // do
-      cy.getByTestid('grw-contextual-sub-nav').should('be.visible').within(() => {
-        cy.getByTestid('open-page-item-control-btn').find('button').first().as('btn').click();
+      cy.get('#grw-subnav-container').within(() => {
+        cy.getByTestid('open-page-item-control-btn', { timeout: 14000 }).find('button').click({force: true});
       });
       // wait until
-      return cy.get('body').within(() => {
-        return Cypress.$('.dropdown-menu.show').is(':visible');
-      });
+      return cy.getByTestid('page-item-control-menu').then($elem => $elem.is(':visible'))
     });
 
     // open modal

+ 6 - 5
apps/app/test/cypress/e2e/23-editor/23-editor--saving.cy.ts

@@ -11,6 +11,7 @@ context('PageCreateModal', () => {
 
   it("PageCreateModal is shown and closed successfully", () => {
     cy.visit('/');
+    cy.collapseSidebar(true, true);
 
     cy.waitUntil(() => {
       // do
@@ -24,13 +25,13 @@ context('PageCreateModal', () => {
       cy.get('button.close').click();
     });
 
-    cy.collapseSidebar(true, true);
     cy.screenshot(`${ssPrefix}page-create-modal-closed`);
   });
 
   it("Successfully Create Today's page", () => {
     const pageName = "Today's page";
     cy.visit('/');
+    cy.collapseSidebar(true);
 
     cy.waitUntil(() => {
       // do
@@ -55,7 +56,6 @@ context('PageCreateModal', () => {
     });
     cy.get('.layout-root').should('not.have.class', 'editing');
 
-    cy.collapseSidebar(true);
     cy.waitUntilSkeletonDisappear();
     cy.screenshot(`${ssPrefix}create-today-page`);
   });
@@ -64,6 +64,7 @@ context('PageCreateModal', () => {
     const pageName = 'child';
 
     cy.visit('/foo/bar');
+    cy.collapseSidebar(true);
 
     cy.waitUntil(() => {
       // do
@@ -94,12 +95,12 @@ context('PageCreateModal', () => {
     cy.getByTestid('grw-contextual-sub-nav').should('be.visible');
 
     cy.waitUntilSkeletonDisappear();
-    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}create-page-under-specific-page`);
   });
 
   it('Trying to create template page under the root page fail', () => {
     cy.visit('/');
+    cy.collapseSidebar(true);
 
     cy.waitUntil(() => {
       // do
@@ -116,8 +117,9 @@ context('PageCreateModal', () => {
       cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
     });
     cy.get('.Toastify__toast').should('be.visible');
-    cy.collapseSidebar(true);
+
     cy.screenshot(`${ssPrefix}create-template-for-children-error`);
+
     cy.get('.Toastify__toast').should('be.visible').within(() => {
       cy.get('.Toastify__close-button').should('be.visible').click();
       cy.get('.Toastify__progress-bar').invoke('attr', 'style', 'display: none')
@@ -129,7 +131,6 @@ context('PageCreateModal', () => {
       cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
     });
     cy.get('.Toastify__toast').should('be.visible');
-    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}create-template-for-descendants-error`);
   });
 

+ 31 - 8
apps/app/test/cypress/e2e/23-editor/23-editor--with-navigation.cy.ts

@@ -38,8 +38,8 @@ context('Editor while uploading to a new page', () => {
 
     // input the body
     const body = 'Hello World!';
-    cy.get('.CodeMirror').type(body + '\n\n');
-    cy.get('.CodeMirror').should('contain.text', body);
+    cy.get('.CodeMirror textarea').type(body + '\n\n', { force: true });
+    cy.get('.CodeMirror-code').should('contain.text', body);
 
     // open GrantSelector
     cy.waitUntil(() => {
@@ -64,21 +64,44 @@ context('Editor while uploading to a new page', () => {
     cy.getByTestid('grw-grant-selector').find('.dropdown-toggle').should('contain.text', 'Only me');
     cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-2`);
 
+    // intercept API req/res for fixing labels
+    const dummyAttachmentId = '64b000000000000000000000';
+    let uploadedAttachmentId = '';
+    cy.intercept('POST', '/_api/attachments.add', (req) => {
+      req.continue((res) => {
+        // store the attachment id
+        uploadedAttachmentId = res.body.attachment._id;
+        // overwrite filePathProxied
+        res.body.attachment.filePathProxied = `/attachment/${dummyAttachmentId}`;
+      });
+    }).as('attachmentsAdd');
+    cy.intercept('GET', `/_api/v3/attachment?attachmentId=${dummyAttachmentId}`, (req) => {
+      // replace attachmentId query
+      req.url = req.url.replace(dummyAttachmentId, uploadedAttachmentId);
+      req.continue((res) => {
+        // overwrite the attachment createdAt
+        res.body.attachment.createdAt = new Date('2023-07-01T00:00:00');
+      });
+    });
+
     // drag-drop a file
-    cy.intercept('POST', '/_api/attachments.add').as('attachmentsAdd');
     const filePath = path.relative('/', path.resolve(Cypress.spec.relative, '../assets/example.txt'));
     cy.get('.dropzone').selectFile(filePath, { action: 'drag-drop' });
-    cy.wait('@attachmentsAdd')
+    cy.wait('@attachmentsAdd');
+
+    cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-3`);
 
     // Update page using shortcut keys
-    cy.get('.CodeMirror').type('{ctrl+s}');
+    cy.get('.CodeMirror').click().type('{ctrl+s}');
+
+    cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-4`);
 
     // expect
     cy.get('.Toastify__toast').should('contain.text', 'Saved successfully');
-    cy.get('.CodeMirror').should('contain.text', body);
-    cy.get('.CodeMirror').should('contain.text', '[example.txt](/attachment/');
+    cy.get('.CodeMirror-code').should('contain.text', body);
+    cy.get('.CodeMirror-code').should('contain.text', '[example.txt](/attachment/64b000000000000000000000');
     cy.getByTestid('grw-grant-selector').find('.dropdown-toggle').should('contain.text', 'Only me');
-    cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-3`);
+    cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-5`);
   });
 
 });

+ 1 - 7
apps/app/test/cypress/e2e/50-sidebar/50-sidebar--access-to-side-bar.cy.ts

@@ -21,11 +21,6 @@ describe('Access to sidebar', () => {
       beforeEach(() => {
         cy.visit('/');
 
-        // Workaround for waitinig initial open/close interaction
-        // TODO: remove this cy.wait() after SSR without the initial interaction is implemented
-        // eslint-disable-next-line cypress/no-unnecessary-waiting
-        cy.wait(2000);
-
         // Since this is a sidebar test, call collapseSidebar in beforeEach.
         cy.collapseSidebar(false);
       });
@@ -212,7 +207,7 @@ describe('Access to sidebar', () => {
         it('Successfully redirect to editor', () => {
           const content = '# HELLO \n ## Hello\n ### Hello';
 
-          cy.get('.grw-sidebar-content-header > h3').find('a').click();
+          cy.get('.grw-sidebar-content-header > h3 > a').should('be.visible').click();
 
           cy.get('.layout-root').should('have.class', 'editing');
           cy.get('.CodeMirror textarea').type(content, {force: true});
@@ -220,7 +215,6 @@ describe('Access to sidebar', () => {
           cy.screenshot(`${ssPrefix}custom-sidebar-2-redirect-to-editor`, { blackout: blackoutOverride });
 
           cy.getByTestid('save-page-btn').click();
-          cy.get('.layout-root').should('not.have.class', 'editing');
         });
 
         it('Successfully create custom sidebar content', () => {

+ 0 - 13
apps/app/test/cypress/e2e/50-sidebar/50-sidebar--switching-sidebar-mode.cy.ts

@@ -9,28 +9,15 @@ const blackoutOverride = [
 context('Switch sidebar mode', () => {
   const ssPrefix = 'switch-sidebar-mode-';
 
-  let connectSid: string | undefined;
-
   before(() => {
     // login
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
-    cy.getCookie('connect.sid').then(cookie => {
-      connectSid = cookie?.value;
-    });
-  });
-
-  beforeEach(() => {
-    if (connectSid != null) {
-      cy.setCookie('connect.sid', connectSid);
-    }
   });
 
   it('Switching sidebar mode', () => {
     cy.visit('/');
-    // This test uses collapseSidebar here, because this test for the sidebar.
-    cy.collapseSidebar(true)
     cy.get('.grw-apperance-mode-dropdown').first().click();
 
     cy.get('[for="swSidebarMode"]').click({force: true});

+ 21 - 0
apps/app/test/cypress/support/assertions.ts

@@ -0,0 +1,21 @@
+// from https://github.com/cypress-io/cypress/issues/877#issuecomment-538708750
+const isInViewport = (_chai) => {
+  function assertIsInViewport() {
+
+    const subject = this._obj;
+
+    const bottom = Cypress.config("viewportWidth");
+    const rect = subject[0].getBoundingClientRect();
+
+    this.assert(
+      rect.top < bottom && rect.bottom < bottom,
+      "expected #{this} to be in viewport",
+      "expected #{this} to not be in viewport",
+      this._obj
+    )
+  }
+
+  _chai.Assertion.addMethod('inViewport', assertIsInViewport)
+};
+
+chai.use(isInViewport);

+ 22 - 19
apps/app/test/cypress/support/commands.ts

@@ -70,31 +70,34 @@ Cypress.Commands.add('waitUntilSpinnerDisappear', () => {
 });
 
 Cypress.Commands.add('collapseSidebar', (isCollapsed: boolean, waitUntilSaving = false) => {
-  cy.getByTestid('grw-sidebar-wrapper', { timeout: 5000 }).within(() => {
+  cy.getByTestid('grw-sidebar').within(($sidebar) => {
+
     // skip if .grw-sidebar-dock does not exist
-    if (isHidden(Cypress.$('.grw-sidebar-dock'))) {
+    if (!$sidebar.hasClass('grw-sidebar-dock')) {
       return;
     }
 
-    // process only when Dock Mode
-    cy.get('.grw-sidebar-dock').within(() => {
-      const isSidebarContextualNavigationHidden = isHiddenByTestId('grw-contextual-navigation-sub');
-      if (isSidebarContextualNavigationHidden === isCollapsed) {
-        return;
-      }
+  });
 
-      cy.waitUntil(() => {
-        // do
-        cy.getByTestid("grw-navigation-resize-button").click({force: true});
-        // wait until saving UserUISettings
-        if (waitUntilSaving) {
-          // eslint-disable-next-line cypress/no-unnecessary-waiting
-          cy.wait(1500);
-        }
+  cy.getByTestid('grw-sidebar').should('be.visible').within(() => {
 
-        // wait until
-        return cy.getByTestid('grw-contextual-navigation-sub').then($contents => isHidden($contents) === isCollapsed);
-      });
+    const isSidebarContextualNavigationHidden = isHiddenByTestId('grw-contextual-navigation-sub');
+    if (isSidebarContextualNavigationHidden === isCollapsed) {
+      return;
+    }
+
+    cy.waitUntil(() => {
+      // do
+      cy.getByTestid("grw-navigation-resize-button").click({force: true});
+      // wait until saving UserUISettings
+      if (waitUntilSaving) {
+        // eslint-disable-next-line cypress/no-unnecessary-waiting
+        cy.wait(1500);
+      }
+
+      // wait until
+      return cy.getByTestid('grw-contextual-navigation-sub').then($contents => isHidden($contents) === isCollapsed);
     });
   });
+
 });

+ 1 - 0
apps/app/test/cypress/support/index.ts

@@ -14,6 +14,7 @@
 // ***********************************************************
 
 // Import commands.js using ES2015 syntax:
+import './assertions'
 import './commands'
 import './screenshot'
 

+ 1 - 1
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "6.1.6-slackbot-proxy.0",
+  "version": "6.1.7-slackbot-proxy.0",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "6.1.6",
+  "version": "6.1.7-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "6.1.6",
+  "version": "6.1.7-RC.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

+ 21 - 0
packages/core/src/interfaces/external-account.ts

@@ -0,0 +1,21 @@
+import type { Ref } from './common';
+import type { HasObjectId } from './has-object-id';
+import type { IUser } from './user';
+
+export const IExternalAuthProviderType = {
+  ldap: 'ldap',
+  saml: 'saml',
+  oicd: 'oidc',
+  google: 'google',
+  github: 'github',
+} as const;
+
+export type IExternalAuthProviderType = typeof IExternalAuthProviderType[keyof typeof IExternalAuthProviderType]
+
+export type IExternalAccount = {
+  providerType: IExternalAuthProviderType,
+  accountId: string,
+  user: Ref<IUser>,
+}
+
+export type IExternalAccountHasId = IExternalAccount & HasObjectId

+ 1 - 0
packages/core/src/interfaces/index.ts

@@ -1,6 +1,7 @@
 export * from './attachment';
 export * from './color-scheme';
 export * from './common';
+export * from './external-account';
 export * from './growi-facade';
 export * from './growi-theme-metadata';
 export * from './has-object-id';

+ 2 - 1
packages/core/src/interfaces/user.ts

@@ -1,5 +1,6 @@
 import type { IAttachment } from './attachment';
 import type { Ref } from './common';
+import type { IExternalAuthProviderType } from './external-account';
 import type { HasObjectId } from './has-object-id';
 import type { Lang } from './lang';
 
@@ -54,7 +55,7 @@ export type IUserGroupRelationHasId = IUserGroupRelation & HasObjectId;
 
 export type IAdminExternalAccount = {
   _id: string,
-  providerType: string,
+  providerType: IExternalAuthProviderType,
   accountId: string,
   user: IUser,
   createdAt: Date,

+ 1 - 1
packages/core/src/utils/page-path-utils/index.ts

@@ -113,7 +113,7 @@ const restrictedPatternsToCreate: Array<RegExp> = [
   /(\/\.\.)\/?/, // see: https://github.com/weseek/growi/issues/3582
   /\\/, // see: https://github.com/weseek/growi/issues/7241
   /^\/(_search|_private-legacy-pages)(\/.*|$)/,
-  /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments|tags|share)(\/.*|$)/,
+  /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments|tags|share|attachment)(\/.*|$)/,
   /^\/user\/[^/]+$/, // see: https://regex101.com/r/utVQct/1
 ];
 export const isCreatablePage = (path: string): boolean => {

+ 1 - 1
packages/hackmd/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/hackmd",
-  "version": "6.1.6",
+  "version": "6.1.7-RC.0",
   "description": "GROWI js and css files to use hackmd",
   "license": "MIT",
   "type": "module",

+ 1 - 1
packages/presentation/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/presentation",
-  "version": "6.1.6",
+  "version": "6.1.7-RC.0",
   "description": "GROWI plugin for presentation",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/preset-templates/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/preset-templates",
-  "version": "6.1.6",
+  "version": "6.1.7-RC.0",
   "scripts": {
     "test": "vitest run",
     "version": "yarn version --no-git-tag-version --preid=RC"

+ 1 - 1
packages/preset-themes/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@growi/preset-themes",
   "description": "GROWI preset themes",
-  "version": "6.1.6",
+  "version": "6.1.7-RC.0",
   "license": "MIT",
   "main": "dist/libs/preset-themes.umd.js",
   "module": "dist/libs/preset-themes.mjs",

+ 1 - 1
packages/remark-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-attachment-refs",
-  "version": "6.1.6",
+  "version": "6.1.7-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/remark-drawio/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-drawio",
-  "version": "6.1.6",
+  "version": "6.1.7-RC.0",
   "description": "remark plugin to draw diagrams with draw.io (diagrams.net)",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/remark-growi-directive/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-growi-directive",
-  "version": "6.1.6",
+  "version": "6.1.7-RC.0",
   "description": "remark plugin to support GROWI plugin (forked from remark-directive@2.0.1)",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/remark-lsx/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-lsx",
-  "version": "6.1.6",
+  "version": "6.1.7-RC.0",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "6.1.6",
+  "version": "6.1.7-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "module": "dist/index.mjs",

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/ui",
-  "version": "6.1.6",
+  "version": "6.1.7-RC.0",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": [

+ 11 - 11
yarn.lock

@@ -2312,13 +2312,13 @@
     xdg-basedir "^4.0.0"
 
 "@growi/core@link:packages/core":
-  version "6.1.6"
+  version "6.1.7-RC.0"
   dependencies:
     bson-objectid "^2.0.4"
     escape-string-regexp "^4.0.0"
 
 "@growi/hackmd@link:packages/hackmd":
-  version "6.1.6"
+  version "6.1.7-RC.0"
 
 "@growi/pluginkit@link:packages/pluginkit":
   version "0.1.0"
@@ -2327,18 +2327,18 @@
     extensible-custom-error "^0.0.7"
 
 "@growi/presentation@link:packages/presentation":
-  version "6.1.6"
+  version "6.1.7-RC.0"
   dependencies:
     "@growi/core" "link:packages/core"
 
 "@growi/preset-templates@link:packages/preset-templates":
-  version "6.1.6"
+  version "6.1.7-RC.0"
 
 "@growi/preset-themes@link:packages/preset-themes":
-  version "6.1.6"
+  version "6.1.7-RC.0"
 
 "@growi/remark-attachment-refs@link:packages/remark-attachment-refs":
-  version "6.1.6"
+  version "6.1.7-RC.0"
   dependencies:
     "@growi/core" "link:packages/core"
     "@growi/remark-growi-directive" "link:packages/remark-growi-directive"
@@ -2347,12 +2347,12 @@
     universal-bunyan "^0.9.2"
 
 "@growi/remark-drawio@link:packages/remark-drawio":
-  version "6.1.6"
+  version "6.1.7-RC.0"
   dependencies:
     pako "^2.1.0"
 
 "@growi/remark-growi-directive@link:packages/remark-growi-directive":
-  version "6.1.6"
+  version "6.1.7-RC.0"
   dependencies:
     "@types/mdast" "^3.0.0"
     "@types/unist" "^2.0.0"
@@ -2369,7 +2369,7 @@
     uvu "^0.5.0"
 
 "@growi/remark-lsx@link:packages/remark-lsx":
-  version "6.1.6"
+  version "6.1.7-RC.0"
   dependencies:
     "@growi/core" "link:packages/core"
     "@growi/remark-growi-directive" "link:packages/remark-growi-directive"
@@ -2380,7 +2380,7 @@
     swr "^2.0.3"
 
 "@growi/slack@link:packages/slack":
-  version "6.1.6"
+  version "6.1.7-RC.0"
   dependencies:
     "@slack/oauth" "^2.0.1"
     axios "^0.24.0"
@@ -2393,7 +2393,7 @@
     url-join "^4.0.0"
 
 "@growi/ui@link:packages/ui":
-  version "6.1.6"
+  version "6.1.7-RC.0"
   dependencies:
     "@growi/core" "link:packages/core"