فهرست منبع

Revert "Revert "Merge branch 'support/apply-nextjs-2' into imprv/show-toc""

This reverts commit 76822819bf7352ba8f68fb5d0322265b03151ea3.
yuken 3 سال پیش
والد
کامیت
e461d9c8e2
44فایلهای تغییر یافته به همراه431 افزوده شده و 265 حذف شده
  1. 1 0
      packages/app/package.json
  2. 11 0
      packages/app/public/static/locales/en_US/admin/admin.json
  3. 11 0
      packages/app/public/static/locales/ja_JP/admin/admin.json
  4. 12 1
      packages/app/public/static/locales/zh_CN/admin/admin.json
  5. 0 49
      packages/app/src/components/BasicLayout.tsx
  6. 30 7
      packages/app/src/components/Drawio.tsx
  7. 5 1
      packages/app/src/components/Layout/AdminLayout.tsx
  8. 48 0
      packages/app/src/components/Layout/BasicLayout.tsx
  9. 0 0
      packages/app/src/components/Layout/RawLayout.tsx
  10. 2 4
      packages/app/src/components/Navbar/GlobalSearch.tsx
  11. 5 3
      packages/app/src/components/PageAlert/PageStaleAlert.tsx
  12. 4 4
      packages/app/src/components/PageList/PageList.tsx
  13. 3 3
      packages/app/src/components/PageList/PageListItemL.tsx
  14. 18 0
      packages/app/src/components/ReactMarkdownComponents/Header.module.scss
  15. 4 1
      packages/app/src/components/ReactMarkdownComponents/Header.tsx
  16. 2 3
      packages/app/src/components/SearchForm.tsx
  17. 2 2
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  18. 4 4
      packages/app/src/components/SearchPage/SearchResultList.tsx
  19. 3 4
      packages/app/src/components/SearchPage2/SearchPageBase.tsx
  20. 5 6
      packages/app/src/components/SearchTypeahead.tsx
  21. 10 10
      packages/app/src/components/Sidebar.module.scss
  22. 50 48
      packages/app/src/components/Sidebar.tsx
  23. 12 4
      packages/app/src/components/SystemVersion.tsx
  24. 41 8
      packages/app/src/interfaces/activity.ts
  25. 8 0
      packages/app/src/interfaces/graph-viewer.ts
  26. 10 6
      packages/app/src/interfaces/search.ts
  27. 4 7
      packages/app/src/pages/[[...path]].page.tsx
  28. 1 1
      packages/app/src/pages/admin/[[...path]].page.tsx
  29. 8 1
      packages/app/src/server/crowi/express-init.js
  30. 4 1
      packages/app/src/server/routes/apiv3/customize-setting.js
  31. 14 2
      packages/app/src/server/routes/apiv3/forgot-password.js
  32. 2 2
      packages/app/src/server/routes/apiv3/page-listing.ts
  33. 4 1
      packages/app/src/server/routes/apiv3/search.js
  34. 25 6
      packages/app/src/server/routes/apiv3/users.js
  35. 6 0
      packages/app/src/server/service/config-loader.ts
  36. 4 2
      packages/app/src/server/service/page.ts
  37. 6 6
      packages/app/src/server/service/search.ts
  38. 2 2
      packages/app/src/stores/page.tsx
  39. 15 8
      packages/app/src/stores/ui.tsx
  40. 15 51
      packages/app/src/styles/_wiki.scss
  41. 2 1
      packages/app/src/styles/style-next.scss
  42. 6 6
      packages/core/src/interfaces/page.ts
  43. 7 0
      packages/core/src/utils/with-utils.ts
  44. 5 0
      yarn.lock

+ 1 - 0
packages/app/package.json

@@ -200,6 +200,7 @@
     "emoji-mart": "npm:panta82-emoji-mart@^3.0.1",
     "eslint-plugin-cypress": "^2.12.1",
     "eslint-plugin-regex": "^1.8.0",
+    "font-awesome": "^4.7.0",
     "handsontable": "=6.2.2",
     "i18next-hmr": "^1.7.7",
     "jquery-slimscroll": "^1.3.8",

+ 11 - 0
packages/app/public/static/locales/en_US/admin/admin.json

@@ -560,6 +560,8 @@
     "USER_LOGIN_WITH_BASIC": "Login with BASIC",
     "USER_LOGIN_FAILURE": "Login failure",
     "USER_LOGOUT": "Logout",
+    "USER_FOGOT_PASSWORD": "Request password reset",
+    "USER_RESET_PASSWORD": "Reset password",
     "USER_PERSONAL_SETTINGS_UPDATE": "User personal settings update",
     "USER_IMAGE_TYPE_UPDATE": "User image type update",
     "USER_LDAP_ACCOUNT_ASSOCIATE": "LDAP account associate",
@@ -647,6 +649,7 @@
     "ADMIN_MARKDOWN_XSS_UPDATE": "Update prevent XSS settings",
     "ADMIN_LAYOUT_UPDATE": "Update Layout",
     "ADMIN_THEME_UPDATE": "Update Theme",
+    "ADMIN_SIDEBAR_UPDATE": "Update Default Sidebar mode",
     "ADMIN_FUNCTION_UPDATE": "Update Function",
     "ADMIN_CODE_HIGHLIGHT_UPDATE": "Update Code Highlight",
     "ADMIN_CUSTOM_TITLE_UPDATE": "Update Custom Title",
@@ -687,10 +690,18 @@
     "ADMIN_SLACK_WITHOUT_PROXY_TEST": "Test connection to Slack bot without proxy",
     "ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE": "Update Slack Incoming Webhooks configuration",
     "ADMIN_USERS_INVITE": "User Invitation",
+    "ADMIN_USERS_PASSWORD_RESET": "Reset user password",
+    "ADMIN_USERS_ACTIVATE": "Activate user",
+    "ADMIN_USERS_DEACTIVATE": "Deactivate user",
+    "ADMIN_USERS_GIVE_ADMIN": "Give admin access",
+    "ADMIN_USERS_REMOVE_ADMIN": "Remove admin access",
+    "ADMIN_USERS_SEND_INVITATION_EMAIL": "Resend invitation email",
+    "ADMIN_USERS_REMOVE": "Remove user",
     "ADMIN_USER_GROUP_CREATE": "Create User Group",
     "ADMIN_USER_GROUP_UPDATE": "Update User Group",
     "ADMIN_USER_GROUP_DELETE": "Delete User Group",
     "ADMIN_USER_GROUP_ADD_USER": "Add User to User Group",
+    "ADMIN_SEARCH_CONNECTION": "Attempting to reconnect to Elasticsearch",
     "ADMIN_SEARCH_INDICES_NORMALIZE": "Normalize of Elasticsearch indexes",
     "ADMIN_SEARCH_INDICES_REBUILD": "Rebuild Elasticsearch indexes"
   }

+ 11 - 0
packages/app/public/static/locales/ja_JP/admin/admin.json

@@ -559,6 +559,8 @@
     "USER_LOGIN_WITH_BASIC": "BASIC 認証でログイン",
     "USER_LOGIN_FAILURE": "ログイン失敗",
     "USER_LOGOUT": "ログアウト",
+    "USER_FOGOT_PASSWORD": "パスワードリセットのリクエスト",
+    "USER_RESET_PASSWORD": "パスワードのリセット",
     "USER_PERSONAL_SETTINGS_UPDATE": "ユーザーの基本情報の更新",
     "USER_IMAGE_TYPE_UPDATE": "プロフィール画像の変更",
     "USER_LDAP_ACCOUNT_ASSOCIATE": "LDAP アカウントの追加",
@@ -646,6 +648,7 @@
     "ADMIN_MARKDOWN_XSS_UPDATE": "XSS 対策設定の更新",
     "ADMIN_LAYOUT_UPDATE": "レイアウト設定の更新",
     "ADMIN_THEME_UPDATE": "テーマ設定の更新",
+    "ADMIN_SIDEBAR_UPDATE": "デフォルトのサイドバーモードの設定の更新",
     "ADMIN_FUNCTION_UPDATE": "機能設定の更新",
     "ADMIN_CODE_HIGHLIGHT_UPDATE": "コードハイライト設定の更新",
     "ADMIN_CUSTOM_TITLE_UPDATE": "カスタムタイトル設定の更新",
@@ -686,10 +689,18 @@
     "ADMIN_SLACK_WITHOUT_PROXY_TEST": "Slack bot without proxy の接続テスト",
     "ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE": "Slack Incoming Webhooks の設定の更新",
     "ADMIN_USERS_INVITE": "ユーザーの招待",
+    "ADMIN_USERS_PASSWORD_RESET": "ユーザーのパスワードをリセット",
+    "ADMIN_USERS_ACTIVATE": "ユーザーを承認する",
+    "ADMIN_USERS_DEACTIVATE": "ユーザーを停止する",
+    "ADMIN_USERS_GIVE_ADMIN": "管理者にする",
+    "ADMIN_USERS_REMOVE_ADMIN": "管理者から外す",
+    "ADMIN_USERS_SEND_INVITATION_EMAIL": "招待メールの再送信",
+    "ADMIN_USERS_REMOVE": "ユーザーの削除",
     "ADMIN_USER_GROUP_CREATE": "ユーザーグループの作成",
     "ADMIN_USER_GROUP_UPDATE": "ユーザーグループの更新",
     "ADMIN_USER_GROUP_DELETE": "ユーザーグループの削除",
     "ADMIN_USER_GROUP_ADD_USER": "ユーザーグループにユーザーを追加",
+    "ADMIN_SEARCH_CONNECTION": "Elasticsearch の再接続の試行",
     "ADMIN_SEARCH_INDICES_NORMALIZE": "Elasticsearch のインデックスの正規化",
     "ADMIN_SEARCH_INDICES_REBUILD": "Elasticsearch のインデックスのリビルド"
   }

+ 12 - 1
packages/app/public/static/locales/zh_CN/admin/admin.json

@@ -569,6 +569,8 @@
     "USER_LOGIN_WITH_BASIC": "使用 BASIC 登录",
     "USER_LOGIN_FAILURE": "登录失败",
     "USER_LOGOUT": "注销",
+    "USER_FOGOT_PASSWORD": "要求重置密码",
+    "USER_RESET_PASSWORD": "重置密码",
     "USER_PERSONAL_SETTINGS_UPDATE": "用户个人设置更新",
     "USER_IMAGE_TYPE_UPDATE": "用户图片类型更新",
     "USER_LDAP_ACCOUNT_ASSOCIATE": "LDAP 帐户关联",
@@ -656,6 +658,7 @@
     "ADMIN_MARKDOWN_XSS_UPDATE": "更新阻止 XSS 设置",
     "ADMIN_LAYOUT_UPDATE": "更新布局",
     "ADMIN_THEME_UPDATE": "更新主题",
+    "ADMIN_SIDEBAR_UPDATE": "更新默认的侧边栏模式",
     "ADMIN_FUNCTION_UPDATE": "更新函数",
     "ADMIN_CODE_HIGHLIGHT_UPDATE": "更新代码高亮",
     "ADMIN_CUSTOM_TITLE_UPDATE": "更新自定义标题",
@@ -696,11 +699,19 @@
     "ADMIN_SLACK_WITHOUT_PROXY_TEST": "在没有代理的情况下测试与 Slack 机器人的连接",
     "ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE": "更新 Slack Incoming Webhooks 配置",
     "ADMIN_USERS_INVITE": "用户邀请",
+    "ADMIN_USERS_PASSWORD_RESET": "重置用户密码",
+    "ADMIN_USERS_ACTIVATE": "激活用户",
+    "ADMIN_USERS_DEACTIVATE": "停用用户",
+    "ADMIN_USERS_GIVE_ADMIN": "授予管理员访问权限",
+    "ADMIN_USERS_REMOVE_ADMIN": "删除管理员访问权限",
+    "ADMIN_USERS_SEND_INVITATION_EMAIL": "重发邀请函",
+    "ADMIN_USERS_REMOVE": "删除用户",
     "ADMIN_USER_GROUP_CREATE": "创建用户组",
     "ADMIN_USER_GROUP_UPDATE": "更新用户组",
     "ADMIN_USER_GROUP_DELETE": "删除用户组",
     "ADMIN_USER_GROUP_ADD_USER": "添加用户到用户组",
-    "ADMIN_SEARCH_INDICES_NORMALIZE": "Elasticsearch 索引的规范化",
+    "ADMIN_SEARCH_CONNECTION": "重试Elasticsearch连接",
+    "ADMIN_SEARCH_INDICES_NORMALIZE": "试图重新连接Elasticsearch",
     "ADMIN_SEARCH_INDICES_REBUILD": "重建 Elasticsearch 索引"
   }
 }

+ 0 - 49
packages/app/src/components/BasicLayout.tsx

@@ -1,49 +0,0 @@
-import React, { ReactNode } from 'react';
-
-import dynamic from 'next/dynamic';
-
-import { GrowiNavbar } from './Navbar/GrowiNavbar';
-import { RawLayout } from './RawLayout';
-import Sidebar from './Sidebar';
-
-
-type Props = {
-  title: string
-  className?: string,
-  children?: ReactNode
-}
-
-export const BasicLayout = ({ children, title, className }: Props): JSX.Element => {
-
-  // const HotkeysManager = dynamic(() => import('../client/js/components/Hotkeys/HotkeysManager'), { ssr: false });
-  // const PageCreateModal = dynamic(() => import('../client/js/components/PageCreateModal'), { ssr: false });
-  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 });
-
-  return (
-    <>
-      <RawLayout title={title} className={className}>
-        <GrowiNavbar />
-
-        <div className="page-wrapper d-flex d-print-block">
-          <div className="grw-sidebar-wrapper">
-            <Sidebar />
-          </div>
-
-          <div className="flex-fill mw-0">
-            {children}
-          </div>
-        </div>
-
-        <GrowiNavbarBottom />
-      </RawLayout>
-
-      {/* <PageCreateModal /> */}
-      {/* <HotkeysManager /> */}
-
-      <ShortcutsModal />
-      <SystemVersion />
-    </>
-  );
-};

+ 30 - 7
packages/app/src/components/Drawio.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useCallback, useEffect, useMemo, useRef,
+  useCallback, useEffect, useMemo, useRef, useState,
 } from 'react';
 
 import EventEmitter from 'events';
@@ -8,35 +8,55 @@ import { useTranslation } from 'next-i18next';
 import { debounce } from 'throttle-debounce';
 
 import { CustomWindow } from '~/interfaces/global';
-import { IGraphViewer } from '~/interfaces/graph-viewer';
+import { IGraphViewer, isGraphViewer } from '~/interfaces/graph-viewer';
 
 import NotAvailableForGuest from './NotAvailableForGuest';
 
 type Props = {
+  GraphViewer: IGraphViewer,
   drawioContent: string,
   rangeLineNumberOfMarkdown: { beginLineNumber: number, endLineNumber: number },
   isPreview?: boolean,
 }
 
+// It calls callback when GraphViewer is not null.
+// eslint-disable-next-line @typescript-eslint/ban-types
+const waitForGraphViewer = async(callback: Function) => {
+  const MAX_WAIT_COUNT = 10; // no reason for 10
+
+  for (let i = 0; i < MAX_WAIT_COUNT; i++) {
+    if (isGraphViewer((window as CustomWindow).GraphViewer)) {
+      callback((window as CustomWindow).GraphViewer);
+      break;
+    }
+    // Sleep 500 ms
+    // eslint-disable-next-line no-await-in-loop
+    await new Promise<void>(r => setTimeout(() => r(), 500));
+  }
+};
+
 const Drawio = (props: Props): JSX.Element => {
 
   const { t } = useTranslation();
 
+  // Wrap with a function since GraphViewer is a function.
+  // This applies when call setGraphViewer as well.
+  const [GraphViewer, setGraphViewer] = useState<IGraphViewer | undefined>(() => (window as CustomWindow).GraphViewer);
+
   const { drawioContent, rangeLineNumberOfMarkdown, isPreview } = props;
 
   // const { open: openDrawioModal } = useDrawioModalForPage();
 
   const drawioContainerRef = useRef<HTMLDivElement>(null);
 
-  const globalEmitter: EventEmitter = useMemo(() => (window as CustomWindow).globalEmitter, []);
-  const GraphViewer: IGraphViewer = useMemo(() => (window as CustomWindow).GraphViewer, []);
+  const globalEmitter: EventEmitter = (window as CustomWindow).globalEmitter;
 
   const editButtonClickHandler = useCallback(() => {
     const { beginLineNumber, endLineNumber } = rangeLineNumberOfMarkdown;
     globalEmitter.emit('launchDrawioModal', beginLineNumber, endLineNumber);
   }, [rangeLineNumberOfMarkdown, globalEmitter]);
 
-  const renderDrawio = useCallback(() => {
+  const renderDrawio = useCallback((GraphViewer: IGraphViewer) => {
     if (drawioContainerRef.current == null) {
       return;
     }
@@ -51,16 +71,19 @@ const Drawio = (props: Props): JSX.Element => {
         GraphViewer.createViewerForElement(div);
       }
     }
-  }, [GraphViewer]);
+  }, [drawioContainerRef]);
 
   const renderDrawioWithDebounce = useMemo(() => debounce(200, renderDrawio), [renderDrawio]);
 
   useEffect(() => {
     if (GraphViewer == null) {
+      waitForGraphViewer((gv: IGraphViewer) => {
+        setGraphViewer(() => gv);
+      });
       return;
     }
 
-    renderDrawioWithDebounce();
+    renderDrawioWithDebounce(GraphViewer);
   }, [renderDrawioWithDebounce, GraphViewer]);
 
   return (

+ 5 - 1
packages/app/src/components/AdminLayout.tsx → packages/app/src/components/Layout/AdminLayout.tsx

@@ -3,7 +3,8 @@ import React, { ReactNode } from 'react';
 import dynamic from 'next/dynamic';
 import { Provider } from 'unstated';
 
-import { GrowiNavbar } from './Navbar/GrowiNavbar';
+import { GrowiNavbar } from '../Navbar/GrowiNavbar';
+
 import { RawLayout } from './RawLayout';
 
 // import { injectableContainers } from '~/client/admin';
@@ -25,6 +26,7 @@ const AdminLayout = ({
 }: Props): JSX.Element => {
 
   const AdminNavigation = dynamic(() => import('~/components/Admin/Common/AdminNavigation'), { ssr: false });
+  const SystemVersion = dynamic(() => import('../SystemVersion'), { ssr: false });
 
   return (
     <RawLayout title={title}>
@@ -48,6 +50,8 @@ const AdminLayout = ({
           </div>
         </div>
       </div>
+
+      <SystemVersion />
     </RawLayout>
   );
 };

+ 48 - 0
packages/app/src/components/Layout/BasicLayout.tsx

@@ -0,0 +1,48 @@
+import React, { ReactNode } from 'react';
+
+import dynamic from 'next/dynamic';
+
+import { GrowiNavbar } from '../Navbar/GrowiNavbar';
+import Sidebar from '../Sidebar';
+
+import { RawLayout } from './RawLayout';
+
+
+type Props = {
+  title: string
+  className?: string,
+  children?: ReactNode
+}
+
+export const BasicLayout = ({ children, title, className }: Props): JSX.Element => {
+
+  // const HotkeysManager = dynamic(() => import('../client/js/components/Hotkeys/HotkeysManager'), { ssr: false });
+  // const PageCreateModal = dynamic(() => import('../client/js/components/PageCreateModal'), { ssr: false });
+  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 });
+
+  return (
+    <RawLayout title={title} className={className}>
+      <GrowiNavbar />
+
+      <div className="page-wrapper d-flex d-print-block">
+        <div className="grw-sidebar-wrapper">
+          <Sidebar />
+        </div>
+
+        <div className="flex-fill mw-0">
+          {children}
+        </div>
+      </div>
+
+      <GrowiNavbarBottom />
+
+      {/* <PageCreateModal /> */}
+      {/* <HotkeysManager /> */}
+
+      <ShortcutsModal />
+      <SystemVersion showShortcutsButton />
+    </RawLayout>
+  );
+};

+ 0 - 0
packages/app/src/components/RawLayout.tsx → packages/app/src/components/Layout/RawLayout.tsx


+ 2 - 4
packages/app/src/components/Navbar/GlobalSearch.tsx

@@ -5,8 +5,7 @@ import assert from 'assert';
 import { useTranslation } from 'next-i18next';
 
 import { IFocusable } from '~/client/interfaces/focusable';
-import { IPageWithMeta } from '~/interfaces/page';
-import { IPageSearchMeta } from '~/interfaces/search';
+import { IPageWithSearchMeta } from '~/interfaces/search';
 import {
   useCurrentPagePath, useIsSearchScopeChildrenAsDefault, useIsSearchServiceReachable,
 } from '~/stores/context';
@@ -14,7 +13,6 @@ import { useGlobalSearchFormRef } from '~/stores/ui';
 
 import SearchForm from '../SearchForm';
 
-
 import styles from './GlobalSearch.module.scss';
 
 
@@ -40,7 +38,7 @@ export const GlobalSearch = (props: GlobalSearchProps): JSX.Element => {
   const [isFocused, setFocused] = useState<boolean>(false);
 
 
-  const gotoPage = useCallback((data: IPageWithMeta<IPageSearchMeta>[]) => {
+  const gotoPage = useCallback((data: IPageWithSearchMeta[]) => {
     assert(data.length > 0);
 
     const page = data[0].data; // should be single page selected

+ 5 - 3
packages/app/src/components/PageAlert/PageStaleAlert.tsx

@@ -1,4 +1,6 @@
-import { useTranslation } from 'react-i18next';
+import { useTranslation } from 'next-i18next';
+
+import { isIPageInfoForEntity } from '~/interfaces/page';
 
 import { useIsEnabledStaleNotification } from '../../stores/context';
 import { useSWRxCurrentPage, useSWRxPageInfo } from '../../stores/page';
@@ -11,7 +13,7 @@ export const PageStaleAlert = ():JSX.Element => {
   const { data: pageData } = useSWRxCurrentPage();
   const { data: pageInfo } = useSWRxPageInfo(isEnabledStaleNotification ? pageData?._id : null);
 
-  const contentAge = pageInfo?.contentAge;
+  const contentAge = isIPageInfoForEntity(pageInfo) ? pageInfo.contentAge : null;
 
   if (!isEnabledStaleNotification) {
     return <></>;
@@ -36,7 +38,7 @@ export const PageStaleAlert = ():JSX.Element => {
   return (
     <div className={`alert ${alertClass}`}>
       <i className="icon-fw icon-hourglass"></i>
-      <strong>{ t('page_page.notice.stale', { count: pageInfo.contentAge }) }</strong>
+      <strong>{ t('page_page.notice.stale', { count: contentAge }) }</strong>
     </div>
   );
 };

+ 4 - 4
packages/app/src/components/PageList/PageList.tsx

@@ -2,7 +2,7 @@ import React from 'react';
 
 import { useTranslation } from 'next-i18next';
 
-import { IPageWithMeta } from '~/interfaces/page';
+import { IPageInfoForEntity, IPageWithMeta } from '~/interfaces/page';
 import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
 
 import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
@@ -10,15 +10,15 @@ import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import { PageListItemL } from './PageListItemL';
 
 
-type Props = {
-  pages: IPageWithMeta[],
+type Props<M extends IPageInfoForEntity> = {
+  pages: IPageWithMeta<M>[],
   isEnableActions?: boolean,
   forceHideMenuItems?: ForceHideMenuItems,
   onPagesDeleted?: OnDeletedFunction,
   onPagePutBacked?: OnPutBackedFunction,
 }
 
-const PageList = (props: Props): JSX.Element => {
+const PageList = (props: Props<IPageInfoForEntity>): JSX.Element => {
   const { t } = useTranslation();
   const {
     pages, isEnableActions, forceHideMenuItems, onPagesDeleted, onPagePutBacked,

+ 3 - 3
packages/app/src/components/PageList/PageListItemL.tsx

@@ -16,9 +16,9 @@ import urljoin from 'url-join';
 import { ISelectable } from '~/client/interfaces/selectable-all';
 import { bookmark, unbookmark } from '~/client/services/page-operation';
 import {
-  IPageInfoAll, IPageInfoForEntity, IPageInfoForListing, IPageWithMeta, isIPageInfoForListing, isIPageInfoForEntity,
+  IPageInfoAll, isIPageInfoForListing, isIPageInfoForEntity, IPageWithMeta, IPageInfoForListing,
 } from '~/interfaces/page';
-import { IPageSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
+import { IPageSearchMeta, IPageWithSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
 import {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
 } from '~/interfaces/ui';
@@ -33,7 +33,7 @@ import { ForceHideMenuItems, PageItemControl } from '../Common/Dropdown/PageItem
 import PagePathHierarchicalLink from '../PagePathHierarchicalLink';
 
 type Props = {
-  page: IPageWithMeta<IPageInfoForEntity> | IPageWithMeta<IPageSearchMeta> | IPageWithMeta<IPageInfoForListing & IPageSearchMeta>,
+  page: IPageWithSearchMeta | IPageWithMeta<IPageInfoForListing & IPageSearchMeta>,
   isSelected?: boolean, // is item selected(focused)
   isEnableActions?: boolean,
   forceHideMenuItems?: ForceHideMenuItems,

+ 18 - 0
packages/app/src/components/ReactMarkdownComponents/Header.module.scss

@@ -0,0 +1,18 @@
+.revision-head :global {
+  a {
+    text-decoration: none;
+  }
+
+  .revision-head-link,
+  .revision-head-edit-button {
+    margin-left: 0.5em;
+    font-size: 0.6em;
+    opacity: 0;
+  }
+}
+
+.revision-head:hover :global {
+  .revision-head-link, .revision-head-edit-button {
+    opacity: 1 !important;
+  }
+}

+ 4 - 1
packages/app/src/components/ReactMarkdownComponents/Header.tsx

@@ -3,6 +3,9 @@ import { Element } from 'react-markdown/lib/rehype-filter';
 import { NextLink } from './NextLink';
 
 
+import styles from './Header.module.scss';
+
+
 type EditLinkProps = {
   line?: number,
 }
@@ -38,7 +41,7 @@ export const Header = (props: HeaderProps): JSX.Element => {
   const CustomTag = `h${level}` as keyof JSX.IntrinsicElements;
 
   return (
-    <CustomTag id={id} className="revision-head">
+    <CustomTag id={id} className={`revision-head ${styles['revision-head']} ${styles.hoge}`}>
       {children}
       <NextLink href={`#${id}`} className="revision-head-link">
         <span className="icon-link"></span>

+ 2 - 3
packages/app/src/components/SearchForm.tsx

@@ -7,8 +7,7 @@ import { useTranslation } from 'next-i18next';
 
 import { IFocusable } from '~/client/interfaces/focusable';
 import { TypeaheadProps } from '~/client/interfaces/react-bootstrap-typeahead';
-import { IPageWithMeta } from '~/interfaces/page';
-import { IPageSearchMeta } from '~/interfaces/search';
+import { IPageWithSearchMeta } from '~/interfaces/search';
 
 import SearchTypeahead from './SearchTypeahead';
 
@@ -84,7 +83,7 @@ type Props = TypeaheadProps & {
 
   keywordOnInit?: string,
   disableIncrementalSearch?: boolean,
-  onChange?: (data: IPageWithMeta<IPageSearchMeta>[]) => void,
+  onChange?: (data: IPageWithSearchMeta[]) => void,
   onSubmit?: (input: string) => void,
 };
 

+ 2 - 2
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -9,7 +9,7 @@ import { exportAsMarkdown } from '~/client/services/page-operation';
 import { toastSuccess } from '~/client/util/apiNotification';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { IPageToDeleteWithMeta, IPageToRenameWithMeta, IPageWithMeta } from '~/interfaces/page';
-import { IPageSearchMeta } from '~/interfaces/search';
+import { IPageWithSearchMeta } from '~/interfaces/search';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import {
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal,
@@ -55,7 +55,7 @@ const MUTATION_OBSERVER_CONFIG = { childList: true, subtree: true };
 
 type Props ={
   appContainer: AppContainer,
-  pageWithMeta : IPageWithMeta<IPageSearchMeta>,
+  pageWithMeta : IPageWithSearchMeta,
   highlightKeywords?: string[],
   showPageControlDropdown?: boolean,
   forceHideMenuItems?: ForceHideMenuItems,

+ 4 - 4
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -10,7 +10,7 @@ import { toastSuccess } from '~/client/util/apiNotification';
 import {
   IPageInfoForListing, IPageWithMeta, isIPageInfoForListing,
 } from '~/interfaces/page';
-import { IPageSearchMeta } from '~/interfaces/search';
+import { IPageSearchMeta, IPageWithSearchMeta } from '~/interfaces/search';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { useIsGuestUser } from '~/stores/context';
 import { useSWRxPageInfoForList, usePageTreeTermManager } from '~/stores/page-listing';
@@ -21,10 +21,10 @@ import { PageListItemL } from '../PageList/PageListItemL';
 
 
 type Props = {
-  pages: IPageWithMeta<IPageSearchMeta>[],
+  pages: IPageWithSearchMeta[],
   selectedPageId?: string,
   forceHideMenuItems?: ForceHideMenuItems,
-  onPageSelected?: (page?: IPageWithMeta<IPageSearchMeta>) => void,
+  onPageSelected?: (page?: IPageWithSearchMeta) => void,
   onCheckboxChanged?: (isChecked: boolean, pageId: string) => void,
 }
 
@@ -73,7 +73,7 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
     }
   }, [onPageSelected, pages]);
 
-  let injectedPages: (IPageWithMeta<IPageSearchMeta> | IPageWithMeta<IPageInfoForListing & IPageSearchMeta>)[] | undefined;
+  let injectedPages: (IPageWithSearchMeta | IPageWithMeta<IPageInfoForListing & IPageSearchMeta>)[] | undefined;
   // inject data to list
   if (idToPageInfo != null) {
     injectedPages = pages.map((page) => {

+ 3 - 4
packages/app/src/components/SearchPage2/SearchPageBase.tsx

@@ -7,8 +7,7 @@ import { useTranslation } from 'next-i18next';
 import { ISelectableAll } from '~/client/interfaces/selectable-all';
 import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess } from '~/client/util/apiNotification';
-import { IPageWithMeta } from '~/interfaces/page';
-import { IFormattedSearchResult, IPageSearchMeta } from '~/interfaces/search';
+import { IFormattedSearchResult, IPageWithSearchMeta } from '~/interfaces/search';
 import { OnDeletedFunction } from '~/interfaces/ui';
 import { useIsGuestUser, useIsSearchServiceConfigured, useIsSearchServiceReachable } from '~/stores/context';
 import { usePageDeleteModal } from '~/stores/modal';
@@ -31,7 +30,7 @@ export interface IReturnSelectedPageIds {
 type Props = {
   appContainer: AppContainer,
 
-  pages?: IPageWithMeta<IPageSearchMeta>[],
+  pages?: IPageWithSearchMeta[],
   searchingKeyword?: string,
 
   forceHideMenuItems?: ForceHideMenuItems,
@@ -61,7 +60,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
 
   const [selectedPageIdsByCheckboxes] = useState<Set<string>>(new Set());
   // const [allPageIds] = useState<Set<string>>(new Set());
-  const [selectedPageWithMeta, setSelectedPageWithMeta] = useState<IPageWithMeta<IPageSearchMeta> | undefined>();
+  const [selectedPageWithMeta, setSelectedPageWithMeta] = useState<IPageWithSearchMeta | undefined>();
 
   // publish selectAll()
   useImperativeHandle(ref, () => ({

+ 5 - 6
packages/app/src/components/SearchTypeahead.tsx

@@ -8,8 +8,7 @@ import { AsyncTypeahead, Menu, MenuItem } from 'react-bootstrap-typeahead';
 
 import { IFocusable } from '~/client/interfaces/focusable';
 import { TypeaheadProps } from '~/client/interfaces/react-bootstrap-typeahead';
-import { IPageWithMeta } from '~/interfaces/page';
-import { IPageSearchMeta } from '~/interfaces/search';
+import { IPageWithSearchMeta } from '~/interfaces/search';
 import { useSWRxSearch } from '~/stores/search';
 
 
@@ -49,7 +48,7 @@ type TypeaheadInstance = {
   clear: () => void,
   focus: () => void,
   toggleMenu: () => void,
-  state: { selected: IPageWithMeta<IPageSearchMeta>[] }
+  state: { selected: IPageWithSearchMeta[] }
 }
 
 const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Props, ref) => {
@@ -132,7 +131,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
   const DELAY_FOR_SUBMISSION = 100;
   const timeoutIdRef = useRef<NodeJS.Timeout>();
 
-  const changeHandler = useCallback((selectedItems: IPageWithMeta<IPageSearchMeta>[]) => {
+  const changeHandler = useCallback((selectedItems: IPageWithSearchMeta[]) => {
     // cancel schedule to submit
     if (timeoutIdRef.current != null) {
       clearTimeout(timeoutIdRef.current);
@@ -165,11 +164,11 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
     }
   }, [onSearchError, searchError]);
 
-  const labelKey = useCallback((option?: IPageWithMeta<IPageSearchMeta>) => {
+  const labelKey = useCallback((option?: IPageWithSearchMeta) => {
     return option?.data.path ?? '';
   }, []);
 
-  const renderMenu = useCallback((options: IPageWithMeta<IPageSearchMeta>[], menuProps) => {
+  const renderMenu = useCallback((options: IPageWithSearchMeta[], menuProps) => {
     if (!isForcused) {
       return <></>;
     }

+ 10 - 10
packages/app/src/components/Sidebar.module.scss

@@ -240,18 +240,18 @@
   }
 }
 
-// '&' could not be set after :global
-// workaround from https://github.com/css-modules/css-modules/issues/295#issuecomment-404873976
-.grw-sidebar :global {
-  .grw-sidebar-drawer {
-    @include drawer();
-  }
-  .grw-sidebar-dock {
-    @include bs.media-breakpoint-down(sm) {
+.grw-sidebar {
+  &:global {
+    &.grw-sidebar-drawer {
       @include drawer();
     }
-    @include bs.media-breakpoint-up(md) {
-      @include dock();
+    &.grw-sidebar-dock {
+      @include bs.media-breakpoint-down(sm) {
+        @include drawer();
+      }
+      @include bs.media-breakpoint-up(md) {
+        @include dock();
+      }
     }
   }
 }

+ 50 - 48
packages/app/src/components/Sidebar.tsx

@@ -88,8 +88,7 @@ const SidebarContentsWrapper = () => {
 
 const Sidebar = (): JSX.Element => {
 
-  // const { data: isDrawerMode } = useDrawerMode(); Todo Universalize
-  const isDrawerMode = false; // dummy
+  const { data: isDrawerMode } = useDrawerMode();
   const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
   const { data: currentProductNavWidth, mutate: mutateProductNavWidth } = useCurrentProductNavWidth();
   const { data: isCollapsed, mutate: mutateSidebarCollapsed } = useSidebarCollapsed();
@@ -287,58 +286,61 @@ const Sidebar = (): JSX.Element => {
 
   const showContents = isDrawerMode || isHover || !isCollapsed;
 
+
+  // css styles
+  const grwSidebarClass = `grw-sidebar ${styles['grw-sidebar']}`;
+  const sidebarModeClass = `${isDrawerMode ? 'grw-sidebar-drawer' : 'grw-sidebar-dock'}`;
+  const isOpenClass = `${isDrawerOpened ? 'open' : ''}`;
   return (
     <>
-      <div className={`grw-sidebar ${styles['grw-sidebar']}`}>
-        <div className={`d-print-none ${isDrawerMode ? 'grw-sidebar-drawer' : 'grw-sidebar-dock'} ${isDrawerOpened ? 'open' : ''}`}>
-          <div className="data-layout-container">
-            <div
-              className='navigation transition-enabled'
-              onMouseEnter={hoverOnHandler}
-              onMouseLeave={hoverOutHandler}
-            >
-              <div className="grw-navigation-wrap">
-                <div className="grw-global-navigation">
-                  <GlobalNavigation></GlobalNavigation>
-                </div>
-                <div
-                  ref={resizableContainer}
-                  className="grw-contextual-navigation"
-                  onMouseEnter={hoverOnResizableContainerHandler}
-                  onMouseLeave={hoverOutResizableContainerHandler}
-                  style={{ width: isCollapsed ? sidebarMinimizeWidth : currentProductNavWidth }}
-                >
-                  <div className="grw-contextual-navigation-child">
-                    <div role="group" data-testid="grw-contextual-navigation-sub" className={`grw-contextual-navigation-sub ${showContents ? '' : 'd-none'}`}>
-                      <SidebarContentsWrapper></SidebarContentsWrapper>
-                    </div>
+      <div className={`${grwSidebarClass} ${sidebarModeClass} ${isOpenClass} d-print-none`}>
+        <div className="data-layout-container">
+          <div
+            className='navigation transition-enabled'
+            onMouseEnter={hoverOnHandler}
+            onMouseLeave={hoverOutHandler}
+          >
+            <div className="grw-navigation-wrap">
+              <div className="grw-global-navigation">
+                <GlobalNavigation></GlobalNavigation>
+              </div>
+              <div
+                ref={resizableContainer}
+                className="grw-contextual-navigation"
+                onMouseEnter={hoverOnResizableContainerHandler}
+                onMouseLeave={hoverOutResizableContainerHandler}
+                style={{ width: isCollapsed ? sidebarMinimizeWidth : currentProductNavWidth }}
+              >
+                <div className="grw-contextual-navigation-child">
+                  <div role="group" data-testid="grw-contextual-navigation-sub" className={`grw-contextual-navigation-sub ${showContents ? '' : 'd-none'}`}>
+                    <SidebarContentsWrapper></SidebarContentsWrapper>
                   </div>
                 </div>
               </div>
-              <div className="grw-navigation-draggable">
-                { isResizableByDrag && (
-                  <div
-                    className="grw-navigation-draggable-hitarea"
-                    onMouseDown={dragableAreaMouseDownHandler}
-                  >
-                    <div className="grw-navigation-draggable-hitarea-child"></div>
-                  </div>
-                ) }
-                <button
-                  data-testid="grw-navigation-resize-button"
-                  className={`grw-navigation-resize-button ${!isDrawerMode ? 'resizable' : ''} ${isCollapsed ? 'collapsed' : ''} `}
-                  type="button"
-                  aria-expanded="true"
-                  aria-label="Toggle navigation"
-                  disabled={isDrawerMode}
-                  onClick={toggleNavigationBtnClickHandler}
+            </div>
+            <div className="grw-navigation-draggable">
+              { isResizableByDrag && (
+                <div
+                  className="grw-navigation-draggable-hitarea"
+                  onMouseDown={dragableAreaMouseDownHandler}
                 >
-                  <span className="hexagon-container" role="presentation">
-                    <NavigationResizeHexagon />
-                  </span>
-                  <span className="hitarea" role="presentation"></span>
-                </button>
-              </div>
+                  <div className="grw-navigation-draggable-hitarea-child"></div>
+                </div>
+              ) }
+              <button
+                data-testid="grw-navigation-resize-button"
+                className={`grw-navigation-resize-button ${!isDrawerMode ? 'resizable' : ''} ${isCollapsed ? 'collapsed' : ''} `}
+                type="button"
+                aria-expanded="true"
+                aria-label="Toggle navigation"
+                disabled={isDrawerMode}
+                onClick={toggleNavigationBtnClickHandler}
+              >
+                <span className="hexagon-container" role="presentation">
+                  <NavigationResizeHexagon />
+                </span>
+                <span className="hitarea" role="presentation"></span>
+              </button>
             </div>
           </div>
         </div>

+ 12 - 4
packages/app/src/components/SystemVersion.tsx

@@ -5,7 +5,13 @@ import { useShortcutsModal } from '~/stores/modal';
 
 import styles from './SystemVersion.module.scss';
 
-const SystemVersion = (): JSX.Element => {
+
+type Props = {
+  showShortcutsButton?: boolean,
+}
+
+const SystemVersion = (props: Props): JSX.Element => {
+  const { showShortcutsButton } = props;
 
   const { open: openShortcutsModal } = useShortcutsModal();
 
@@ -21,9 +27,11 @@ const SystemVersion = (): JSX.Element => {
         <span>
           <a href="https://growi.org">GROWI</a> {growiVersion}
         </span>
-        <button type="button" className="btn btn-link ml-2 p-0" onClick={() => openShortcutsModal()}>
-          <i className="fa fa-keyboard-o"></i>&nbsp;<span className={`cmd-key ${os}`}></span>-/
-        </button>
+        { showShortcutsButton && (
+          <button type="button" className="btn btn-link ml-2 p-0" onClick={() => openShortcutsModal()}>
+            <i className="fa fa-keyboard-o"></i>&nbsp;<span className={`cmd-key ${os}`}></span>-/
+          </button>
+        ) }
       </div>
 
     </>

+ 41 - 8
packages/app/src/interfaces/activity.ts

@@ -19,6 +19,8 @@ const ACTION_USER_LOGIN_WITH_SAML = 'USER_LOGIN_WITH_SAML';
 const ACTION_USER_LOGIN_WITH_BASIC = 'USER_LOGIN_WITH_BASIC';
 const ACTION_USER_LOGIN_FAILURE = 'USER_LOGIN_FAILURE';
 const ACTION_USER_LOGOUT = 'USER_LOGOUT';
+const ACTION_USER_FOGOT_PASSWORD = 'USER_FOGOT_PASSWORD';
+const ACTION_USER_RESET_PASSWORD = 'USER_RESET_PASSWORD';
 const ACTION_USER_PERSONAL_SETTINGS_UPDATE = 'USER_PERSONAL_SETTINGS_UPDATE';
 const ACTION_USER_IMAGE_TYPE_UPDATE = 'USER_IMAGE_TYPE_UPDATE';
 const ACTION_USER_LDAP_ACCOUNT_ASSOCIATE = 'USER_LDAP_ACCOUNT_ASSOCIATE';
@@ -106,6 +108,7 @@ const ACTION_ADMIN_MARKDOWN_PRESENTATION_UPDATE = 'ADMIN_MARKDOWN_PRESENTATION_U
 const ACTION_ADMIN_MARKDOWN_XSS_UPDATE = 'ADMIN_MARKDOWN_XSS_UPDATE';
 const ACTION_ADMIN_LAYOUT_UPDATE = 'ADMIN_LAYOUT_UPDATE';
 const ACTION_ADMIN_THEME_UPDATE = 'ADMIN_THEME_UPDATE';
+const ACTION_ADMIN_SIDEBAR_UPDATE = 'ADMIN_SIDEBAR_UPDATE';
 const ACTION_ADMIN_FUNCTION_UPDATE = 'ADMIN_FUNCTION_UPDATE';
 const ACTION_ADMIN_CODE_HIGHLIGHT_UPDATE = 'ADMIN_CODE_HIGHLIGHT_UPDATE';
 const ACTION_ADMIN_CUSTOM_TITLE_UPDATE = 'ADMIN_CUSTOM_TITLE_UPDATE';
@@ -146,10 +149,18 @@ const ACTION_ADMIN_SLACK_WITHOUT_PROXY_PERMISSION_UPDATE = 'ADMIN_SLACK_WITHOUT_
 const ACTION_ADMIN_SLACK_WITHOUT_PROXY_TEST = 'ADMIN_SLACK_WITHOUT_PROXY_TEST';
 const ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE = 'ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE';
 const ACTION_ADMIN_USERS_INVITE = 'ADMIN_USERS_INVITE';
+const ACTION_ADMIN_USERS_PASSWORD_RESET = 'ADMIN_USERS_PASSWORD_RESET';
+const ACTION_ADMIN_USERS_ACTIVATE = 'ADMIN_USERS_ACTIVATE';
+const ACTION_ADMIN_USERS_GIVE_ADMIN = 'ADMIN_USERS_GIVE_ADMIN';
+const ACTION_ADMIN_USERS_REMOVE_ADMIN = 'ADMIN_USERS_REMOVE_ADMIN';
+const ACTION_ADMIN_USERS_DEACTIVATE = 'ADMIN_USERS_DEACTIVATE';
+const ACTION_ADMIN_USERS_SEND_INVITATION_EMAIL = 'ADMIN_USERS_SEND_INVITATION_EMAIL';
+const ACTION_ADMIN_USERS_REMOVE = 'ADMIN_USERS_REMOVE';
 const ACTION_ADMIN_USER_GROUP_CREATE = 'ADMIN_USER_GROUP_CREATE';
 const ACTION_ADMIN_USER_GROUP_UPDATE = 'ADMIN_USER_GROUP_UPDATE';
 const ACTION_ADMIN_USER_GROUP_DELETE = 'ADMIN_USER_GROUP_DELETE';
 const ACTION_ADMIN_USER_GROUP_ADD_USER = 'ADMIN_USER_GROUP_ADD_USER';
+const ACTION_ADMIN_SEARCH_CONNECTION = 'ADMIN_SEARCH_CONNECTION';
 const ACTION_ADMIN_SEARCH_INDICES_NORMALIZE = 'ADMIN_SEARCH_INDICES_NORMALIZE';
 const ACTION_ADMIN_SEARCH_INDICES_REBUILD = 'ADMIN_SEARCH_INDICES_REBUILD';
 
@@ -187,6 +198,8 @@ export const SupportedAction = {
   ACTION_USER_LOGIN_WITH_BASIC,
   ACTION_USER_LOGIN_FAILURE,
   ACTION_USER_LOGOUT,
+  ACTION_USER_FOGOT_PASSWORD,
+  ACTION_USER_RESET_PASSWORD,
   ACTION_USER_PERSONAL_SETTINGS_UPDATE,
   ACTION_USER_IMAGE_TYPE_UPDATE,
   ACTION_USER_LDAP_ACCOUNT_ASSOCIATE,
@@ -274,6 +287,7 @@ export const SupportedAction = {
   ACTION_ADMIN_MARKDOWN_XSS_UPDATE,
   ACTION_ADMIN_LAYOUT_UPDATE,
   ACTION_ADMIN_THEME_UPDATE,
+  ACTION_ADMIN_SIDEBAR_UPDATE,
   ACTION_ADMIN_FUNCTION_UPDATE,
   ACTION_ADMIN_CODE_HIGHLIGHT_UPDATE,
   ACTION_ADMIN_CUSTOM_TITLE_UPDATE,
@@ -314,10 +328,18 @@ export const SupportedAction = {
   ACTION_ADMIN_SLACK_WITHOUT_PROXY_TEST,
   ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE,
   ACTION_ADMIN_USERS_INVITE,
+  ACTION_ADMIN_USERS_PASSWORD_RESET,
+  ACTION_ADMIN_USERS_ACTIVATE,
+  ACTION_ADMIN_USERS_DEACTIVATE,
+  ACTION_ADMIN_USERS_GIVE_ADMIN,
+  ACTION_ADMIN_USERS_REMOVE_ADMIN,
+  ACTION_ADMIN_USERS_SEND_INVITATION_EMAIL,
+  ACTION_ADMIN_USERS_REMOVE,
   ACTION_ADMIN_USER_GROUP_CREATE,
   ACTION_ADMIN_USER_GROUP_UPDATE,
   ACTION_ADMIN_USER_GROUP_DELETE,
   ACTION_ADMIN_USER_GROUP_ADD_USER,
+  ACTION_ADMIN_SEARCH_CONNECTION,
   ACTION_ADMIN_SEARCH_INDICES_NORMALIZE,
   ACTION_ADMIN_SEARCH_INDICES_REBUILD,
 } as const;
@@ -360,6 +382,8 @@ export const SmallActionGroup = {
 export const MediumActionGroup = {
   ...SmallActionGroup,
   ACTION_USER_REGISTRATION_SUCCESS,
+  ACTION_USER_FOGOT_PASSWORD,
+  ACTION_USER_RESET_PASSWORD,
   ACTION_USER_PERSONAL_SETTINGS_UPDATE,
   ACTION_USER_IMAGE_TYPE_UPDATE,
   ACTION_USER_LDAP_ACCOUNT_ASSOCIATE,
@@ -444,6 +468,7 @@ export const LargeActionGroup = {
   ACTION_ADMIN_MARKDOWN_XSS_UPDATE,
   ACTION_ADMIN_LAYOUT_UPDATE,
   ACTION_ADMIN_THEME_UPDATE,
+  ACTION_ADMIN_SIDEBAR_UPDATE,
   ACTION_ADMIN_FUNCTION_UPDATE,
   ACTION_ADMIN_CODE_HIGHLIGHT_UPDATE,
   ACTION_ADMIN_CUSTOM_TITLE_UPDATE,
@@ -451,6 +476,14 @@ export const LargeActionGroup = {
   ACTION_ADMIN_CUSTOM_CSS_UPDATE,
   ACTION_ADMIN_CUSTOM_SCRIPT_UPDATE,
   ACTION_ADMIN_ARCHIVE_DATA_UPLOAD,
+  ACTION_ADMIN_GROWI_DATA_IMPORTED,
+  ACTION_ADMIN_ESA_DATA_IMPORTED,
+  ACTION_ADMIN_QIITA_DATA_IMPORTED,
+  ACTION_ADMIN_UPLOADED_GROWI_DATA_DISCARDED,
+  ACTION_ADMIN_ESA_DATA_UPDATED,
+  ACTION_ADMIN_CONNECTION_TEST_OF_ESA_DATA,
+  ACTION_ADMIN_QIITA_DATA_UPDATED,
+  ACTION_ADMIN_CONNECTION_TEST_OF_QIITA_DATA,
   ACTION_ADMIN_ARCHIVE_DATA_CREATE,
   ACTION_ADMIN_ARCHIVE_DATA_DOWNLOAD,
   ACTION_ADMIN_ARCHIVE_DATA_DELETE,
@@ -476,20 +509,20 @@ export const LargeActionGroup = {
   ACTION_ADMIN_SLACK_WITHOUT_PROXY_TEST,
   ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE,
   ACTION_ADMIN_USERS_INVITE,
+  ACTION_ADMIN_USERS_PASSWORD_RESET,
+  ACTION_ADMIN_USERS_ACTIVATE,
+  ACTION_ADMIN_USERS_DEACTIVATE,
+  ACTION_ADMIN_USERS_GIVE_ADMIN,
+  ACTION_ADMIN_USERS_REMOVE_ADMIN,
+  ACTION_ADMIN_USERS_SEND_INVITATION_EMAIL,
+  ACTION_ADMIN_USERS_REMOVE,
   ACTION_ADMIN_USER_GROUP_CREATE,
   ACTION_ADMIN_USER_GROUP_UPDATE,
   ACTION_ADMIN_USER_GROUP_DELETE,
   ACTION_ADMIN_USER_GROUP_ADD_USER,
+  ACTION_ADMIN_SEARCH_CONNECTION,
   ACTION_ADMIN_SEARCH_INDICES_NORMALIZE,
   ACTION_ADMIN_SEARCH_INDICES_REBUILD,
-  ACTION_ADMIN_GROWI_DATA_IMPORTED,
-  ACTION_ADMIN_ESA_DATA_IMPORTED,
-  ACTION_ADMIN_QIITA_DATA_IMPORTED,
-  ACTION_ADMIN_UPLOADED_GROWI_DATA_DISCARDED,
-  ACTION_ADMIN_ESA_DATA_UPDATED,
-  ACTION_ADMIN_CONNECTION_TEST_OF_ESA_DATA,
-  ACTION_ADMIN_QIITA_DATA_UPDATED,
-  ACTION_ADMIN_CONNECTION_TEST_OF_QIITA_DATA,
 } as const;
 
 

+ 8 - 0
packages/app/src/interfaces/graph-viewer.ts

@@ -1,3 +1,11 @@
 export interface IGraphViewer {
   createViewerForElement: (Element) => void,
 }
+
+export const isGraphViewer = (val: any): val is IGraphViewer => {
+  if (typeof val === 'function' && typeof val.createViewerForElement === 'function') {
+    return true;
+  }
+
+  return false;
+};

+ 10 - 6
packages/app/src/interfaces/search.ts

@@ -1,4 +1,4 @@
-import { IPageWithMeta } from './page';
+import { IDataWithMeta, IPageHasId } from './page';
 
 export type IPageSearchMeta = {
   bookmarkCount?: number,
@@ -14,10 +14,6 @@ export const isIPageSearchMeta = (meta: any): meta is IPageSearchMeta => {
   return meta != null && 'elasticSearchResult' in meta;
 };
 
-export type ISearchResult<T > = ISearchResultMeta & {
-  data: T[],
-}
-
 export type ISearchResultMeta = {
   meta: {
     took?: number
@@ -26,7 +22,15 @@ export type ISearchResultMeta = {
   },
 }
 
-export type IFormattedSearchResult = ISearchResult<IPageWithMeta<IPageSearchMeta>>;
+export type ISearchResult<T> = ISearchResultMeta & {
+  data: T[],
+}
+
+export type IPageWithSearchMeta = IDataWithMeta<IPageHasId, IPageSearchMeta>;
+
+export type IFormattedSearchResult = ISearchResultMeta & {
+  data: IPageWithSearchMeta[],
+}
 
 export const SORT_AXIS = {
   RELATION_SCORE: 'relationScore',

+ 4 - 7
packages/app/src/pages/[[...path]].page.tsx

@@ -34,12 +34,11 @@ import {
 } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
-
 // import { isUserPage, isTrashPage, isSharedPage } from '~/utils/path-utils';
 
 // import GrowiSubNavigation from '../client/js/components/Navbar/GrowiSubNavigation';
 // import GrowiSubNavigationSwitcher from '../client/js/components/Navbar/GrowiSubNavigationSwitcher';
-import { BasicLayout } from '../components/BasicLayout';
+import { BasicLayout } from '../components/Layout/BasicLayout';
 import GrowiContextualSubNavigation from '../components/Navbar/GrowiContextualSubNavigation';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 
@@ -255,10 +254,9 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
         <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
 
         <div id="main" className={`main ${isUsersHomePage(props.currentPathname) && 'user-page'}`}>
-
-          <div className="row">
-            <div className="col">
-              <div id="content-main" className="content-main grw-container-convertible">
+          <div id="content-main" className="content-main grw-container-convertible">
+            <div className="row">
+              <div className="col">
                 { props.isIdenticalPathPage && <IdenticalPathPage /> }
 
                 { !props.isIdenticalPathPage && (
@@ -283,7 +281,6 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
               </div>
             </div> */}
           </div>
-
         </div>
         <footer>
           {/* <PageComments /> */}

+ 1 - 1
packages/app/src/pages/admin/[[...path]].page.tsx

@@ -14,7 +14,7 @@ import NotificationSetting from '~/components/Admin/Notification/NotificationSet
 import SecurityManagementContents from '~/components/Admin/Security/SecurityManagementContents';
 import UserGroupPage from '~/components/Admin/UserGroup/UserGroupPage';
 import UserManagement from '~/components/Admin/UserManagement';
-import AdminLayout from '~/components/AdminLayout';
+import AdminLayout from '~/components/Layout/AdminLayout';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CommonProps, getServerSideCommonProps, useCustomTitle } from '~/pages/commons';
 import PluginUtils from '~/server/plugins/plugin-utils';

+ 8 - 1
packages/app/src/server/crowi/express-init.js

@@ -59,6 +59,12 @@ module.exports = function(crowi, app) {
 
   app.use(compression());
 
+  const { configManager } = crowi;
+  const trustedProxies = configManager.getConfig('crowi', 'security:trustedProxies');
+  if (trustedProxies != null) {
+    app.set('trust proxy', trustedProxies);
+  }
+
   app.use(helmet({
     contentSecurityPolicy: false,
     expectCt: false,
@@ -122,7 +128,8 @@ module.exports = function(crowi, app) {
   });
 
   // csurf should be initialized after express-session
-  app.use(csrf({ cookie: false }));
+  // default methods + PUT. See: https://expressjs.com/en/resources/middleware/csurf.html#ignoremethods
+  app.use(csrf({ ignoreMethods: ['GET', 'HEAD', 'OPTIONS', 'PUT'], cookie: false }));
 
   // passport
   debug('initialize Passport');

+ 4 - 1
packages/app/src/server/routes/apiv3/customize-setting.js

@@ -364,7 +364,7 @@ module.exports = (crowi) => {
     }
   });
 
-  router.put('/sidebar', loginRequiredStrictly, adminRequired, validator.sidebar, apiV3FormValidator, async(req, res) => {
+  router.put('/sidebar', loginRequiredStrictly, adminRequired, validator.sidebar, apiV3FormValidator, addActivity, async(req, res) => {
     const requestParams = {
       'customize:isSidebarDrawerMode': req.body.isSidebarDrawerMode,
       'customize:isSidebarClosedAtDockMode': req.body.isSidebarClosedAtDockMode,
@@ -376,6 +376,9 @@ module.exports = (crowi) => {
         isSidebarDrawerMode: await crowi.configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
         isSidebarClosedAtDockMode: await crowi.configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
       };
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SIDEBAR_UPDATE });
+
       return res.apiv3({ customizedParams });
     }
     catch (err) {

+ 14 - 2
packages/app/src/server/routes/apiv3/forgot-password.js

@@ -1,5 +1,7 @@
 import { format, subSeconds } from 'date-fns';
 
+import { SupportedAction } from '~/interfaces/activity';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import injectResetOrderByTokenMiddleware from '~/server/middlewares/inject-reset-order-by-token-middleware';
 import PasswordResetOrder from '~/server/models/password-reset-order';
 import ErrorV3 from '~/server/models/vo/error-apiv3';
@@ -24,6 +26,10 @@ module.exports = (crowi) => {
   const User = crowi.model('User');
   const path = require('path');
 
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const activityEvent = crowi.event('activity');
+
   const minPasswordLength = crowi.configManager.getConfig('crowi', 'app:minPasswordLength');
 
   const validator = {
@@ -55,7 +61,7 @@ module.exports = (crowi) => {
     });
   }
 
-  router.post('/', checkPassportStrategyMiddleware, async(req, res) => {
+  router.post('/', checkPassportStrategyMiddleware, addActivity, async(req, res) => {
     const { email } = req.body;
     const i18n = configManager.getConfig('crowi', 'app:globalLang');
     const appUrl = appService.getSiteUrl();
@@ -76,6 +82,9 @@ module.exports = (crowi) => {
       const expiredAt = subSeconds(passwordResetOrderData.expiredAt, grwTzoffsetSec);
       const formattedExpiredAt = format(expiredAt, 'yyyy/MM/dd HH:mm');
       await sendPasswordResetEmail('passwordReset', i18n, email, oneTimeUrl, formattedExpiredAt);
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_USER_FOGOT_PASSWORD });
+
       return res.apiv3();
     }
     catch (err) {
@@ -86,7 +95,7 @@ module.exports = (crowi) => {
   });
 
   // eslint-disable-next-line max-len
-  router.put('/', checkPassportStrategyMiddleware, injectResetOrderByTokenMiddleware, validator.password, apiV3FormValidator, async(req, res) => {
+  router.put('/', checkPassportStrategyMiddleware, injectResetOrderByTokenMiddleware, validator.password, apiV3FormValidator, addActivity, async(req, res) => {
     const { passwordResetOrder } = req;
     const { email } = passwordResetOrder;
     const grobalLang = configManager.getConfig('crowi', 'app:globalLang');
@@ -105,6 +114,9 @@ module.exports = (crowi) => {
       const serializedUserData = serializeUserSecurely(userData);
       passwordResetOrder.revokeOneTimeToken();
       await sendPasswordResetEmail('passwordResetSuccessful', i18n, email);
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_USER_RESET_PASSWORD });
+
       return res.apiv3({ userData: serializedUserData });
     }
     catch (err) {

+ 2 - 2
packages/app/src/server/routes/apiv3/page-listing.ts

@@ -2,7 +2,7 @@ import express, { Request, Router } from 'express';
 import { query, oneOf } from 'express-validator';
 import mongoose from 'mongoose';
 
-import { IPageInfoAll, isIPageInfoForEntity, IPageInfoForListing } from '~/interfaces/page';
+import { isIPageInfoForEntity, IPageInfoForListing, IPageInfo } from '~/interfaces/page';
 import { IUserHasId } from '~/interfaces/user';
 import loggerFactory from '~/utils/logger';
 
@@ -134,7 +134,7 @@ const routerFactory = (crowi: Crowi): Router => {
         bookmarkCountMap = await Bookmark.getPageIdToCountMap(foundIds) as Record<string, number>;
       }
 
-      const idToPageInfoMap: Record<string, IPageInfoAll> = {};
+      const idToPageInfoMap: Record<string, IPageInfo | IPageInfoForListing> = {};
 
       const isGuestUser = req.user == null;
       for (const page of pages) {

+ 4 - 1
packages/app/src/server/routes/apiv3/search.js

@@ -75,7 +75,7 @@ module.exports = (crowi) => {
    *        200:
    *          description: Successfully connected
    */
-  router.post('/connection', accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+  router.post('/connection', accessTokenParser, loginRequired, adminRequired, addActivity, async(req, res) => {
     const { searchService } = crowi;
 
     if (!searchService.isConfigured) {
@@ -84,6 +84,9 @@ module.exports = (crowi) => {
 
     try {
       await searchService.reconnectClient();
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SEARCH_CONNECTION });
+
       return res.status(200).send();
     }
     catch (err) {

+ 25 - 6
packages/app/src/server/routes/apiv3/users.js

@@ -463,12 +463,15 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: data of admin user
    */
-  router.put('/:id/giveAdmin', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.put('/:id/giveAdmin', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
     const { id } = req.params;
 
     try {
       const userData = await User.findById(id);
       await userData.makeAdmin();
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_GIVE_ADMIN });
+
       return res.apiv3({ userData });
     }
     catch (err) {
@@ -505,12 +508,15 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: data of removed admin user
    */
-  router.put('/:id/removeAdmin', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.put('/:id/removeAdmin', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
     const { id } = req.params;
 
     try {
       const userData = await User.findById(id);
       await userData.removeFromAdmin();
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_REMOVE_ADMIN });
+
       return res.apiv3({ userData });
     }
     catch (err) {
@@ -546,7 +552,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: data of activate user
    */
-  router.put('/:id/activate', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.put('/:id/activate', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
     // check user upper limit
     const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
     if (isUserCountExceedsUpperLimit) {
@@ -560,6 +566,9 @@ module.exports = (crowi) => {
     try {
       const userData = await User.findById(id);
       await userData.statusActivate();
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_ACTIVATE });
+
       return res.apiv3({ userData });
     }
     catch (err) {
@@ -595,12 +604,15 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: data of deactivate user
    */
-  router.put('/:id/deactivate', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.put('/:id/deactivate', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
     const { id } = req.params;
 
     try {
       const userData = await User.findById(id);
       await userData.statusSuspend();
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_DEACTIVATE });
+
       return res.apiv3({ userData });
     }
     catch (err) {
@@ -636,7 +648,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: data of delete user
    */
-  router.delete('/:id/remove', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.delete('/:id/remove', loginRequiredStrictly, adminRequired,  addActivity, async(req, res) => {
     const { id } = req.params;
 
     try {
@@ -646,6 +658,8 @@ module.exports = (crowi) => {
       await ExternalAccount.remove({ user: userData });
       await Page.removeByPath(`/user/${userData.username}`);
 
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_REMOVE });
+
       return res.apiv3({ userData });
     }
     catch (err) {
@@ -821,6 +835,8 @@ module.exports = (crowi) => {
         await User.resetPasswordByRandomString(id),
         await User.findById(id)]);
 
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_PASSWORD_RESET });
+
       return res.apiv3({ newPassword, user });
     }
     catch (err) {
@@ -858,7 +874,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: email and reasons for email sending failure
    */
-  router.put('/send-invitation-email', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.put('/send-invitation-email', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
     const { id } = req.body;
 
     try {
@@ -871,6 +887,9 @@ module.exports = (crowi) => {
       }];
       const sendEmail = await sendEmailByUserList(userList);
       // return null if absent
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_SEND_INVITATION_EMAIL });
+
       return res.apiv3({ failedToSendEmail: sendEmail.failedToSendEmailList[0] });
     }
     catch (err) {

+ 6 - 0
packages/app/src/server/service/config-loader.ts

@@ -358,6 +358,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.BOOLEAN,
     default: false,
   },
+  TRUSTED_PROXIES: {
+    ns:      'crowi',
+    key:     'security:trustedProxies',
+    type:    ValueType.STRING,
+    default: null,
+  },
   LOCAL_STRATEGY_ENABLED: {
     ns:      'crowi',
     key:     'security:passport-local:isEnabled',

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

@@ -10,7 +10,7 @@ import streamToPromise from 'stream-to-promise';
 
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import {
-  IPage, IPageInfo, IPageInfoForEntity, IPageWithMeta,
+  IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta,
 } from '~/interfaces/page';
 import {
   PageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation,
@@ -216,7 +216,9 @@ class PageService {
   }
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  async findPageAndMetaDataByViewer(pageId: string, path: string, user: IUserHasId, includeEmpty = false, isSharedPage = false): Promise<IPageWithMeta|null> {
+  async findPageAndMetaDataByViewer(
+      pageId: string, path: string, user: IUserHasId, includeEmpty = false, isSharedPage = false,
+  ): Promise<IPageWithMeta<IPageInfoAll>|null> {
 
     const Page = this.crowi.model('Page');
 

+ 6 - 6
packages/app/src/server/service/search.ts

@@ -2,8 +2,8 @@ import mongoose from 'mongoose';
 import xss from 'xss';
 
 import { SearchDelegatorName } from '~/interfaces/named-query';
-import { IPageWithMeta } from '~/interfaces/page';
-import { IFormattedSearchResult, IPageSearchMeta, ISearchResult } from '~/interfaces/search';
+import { IPageHasId } from '~/interfaces/page';
+import { IFormattedSearchResult, IPageWithSearchMeta, ISearchResult } from '~/interfaces/search';
 import loggerFactory from '~/utils/logger';
 
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
@@ -400,9 +400,9 @@ class SearchService implements SearchQueryParser, SearchResolver {
    */
   async formatSearchResult(searchResult: ISearchResult<any>, delegatorName: SearchDelegatorName, user, userGroups): Promise<IFormattedSearchResult> {
     if (!this.checkIsFormattable(searchResult, delegatorName)) {
-      const data: IPageWithMeta<IPageSearchMeta>[] = searchResult.data.map((page) => {
+      const data: IPageWithSearchMeta[] = searchResult.data.map((page) => {
         return {
-          data: page,
+          data: page as IPageHasId,
         };
       });
 
@@ -419,7 +419,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
     const result = {} as IFormattedSearchResult;
 
     // get page data
-    const pageIds = searchResult.data.map((page) => { return page._id });
+    const pageIds: string[] = searchResult.data.map((page) => { return page._id });
 
     const findPageResult = await findPageListByIds(pageIds, this.crowi);
 
@@ -427,7 +427,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
     result.meta = searchResult.meta;
 
     // set search result page data
-    const pages: (IPageWithMeta<IPageSearchMeta> | null)[] = searchResult.data.map((data) => {
+    const pages: (IPageWithSearchMeta | null)[] = searchResult.data.map((data) => {
       const pageData = findPageResult.pages.find((pageData) => {
         return pageData.id === data._id;
       });

+ 2 - 2
packages/app/src/stores/page.tsx

@@ -1,4 +1,4 @@
-import { IPagePopulatedToShowRevision, Nullable } from '@growi/core';
+import { IPageInfoForEntity, IPagePopulatedToShowRevision, Nullable } from '@growi/core';
 import useSWR, { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
@@ -65,7 +65,7 @@ export const useSWRxTagsInfo = (pageId: Nullable<string>): SWRResponse<IPageTags
 export const useSWRxPageInfo = (
     pageId: string | null | undefined,
     shareLinkId?: string | null,
-    initialData?: IPageInfoAll,
+    initialData?: IPageInfoForEntity,
 ): SWRResponse<IPageInfo | IPageInfoForOperation, Error> => {
 
   // assign null if shareLinkId is undefined in order to identify SWR key only by pageId

+ 15 - 8
packages/app/src/stores/ui.tsx

@@ -3,6 +3,7 @@ import { RefObject } from 'react';
 import {
   isClient, isServer, pagePathUtils, Nullable,
 } from '@growi/core';
+import { withUtils, SWRResponseWithUtils } from '@growi/core/src/utils/with-utils';
 import { Breakpoint, addBreakpointListener } from '@growi/ui';
 import SimpleBar from 'simplebar-react';
 import {
@@ -222,15 +223,13 @@ type PreferDrawerModeByUserUtils = {
   update: (preferDrawerMode: boolean) => void
 }
 
-export const usePreferDrawerModeByUser = (initialData?: boolean): SWRResponse<boolean, Error> & PreferDrawerModeByUserUtils => {
+export const usePreferDrawerModeByUser = (initialData?: boolean): SWRResponseWithUtils<SWRResponse, PreferDrawerModeByUserUtils> => {
   const { data: isGuestUser } = useIsGuestUser();
   const { scheduleToPut } = useUserUISettings();
 
   const swrResponse: SWRResponse<boolean, Error> = useStaticSWR('preferDrawerModeByUser', initialData, { use: isGuestUser ? [localStorageMiddleware] : [] });
 
-  return {
-    ...swrResponse,
-    data: swrResponse.data,
+  const utils: PreferDrawerModeByUserUtils = {
     update: (preferDrawerMode: boolean) => {
       swrResponse.mutate(preferDrawerMode);
 
@@ -239,6 +238,9 @@ export const usePreferDrawerModeByUser = (initialData?: boolean): SWRResponse<bo
       }
     },
   };
+
+  return withUtils(swrResponse, utils);
+
 };
 
 export const usePreferDrawerModeOnEditByUser = (initialData?: boolean): SWRResponse<boolean, Error> => {
@@ -258,9 +260,9 @@ export const useCurrentProductNavWidth = (initialData?: number): SWRResponse<num
 };
 
 export const useDrawerMode = (): SWRResponse<boolean, Error> => {
-  const { data: editorMode } = useEditorMode();
   const { data: preferDrawerModeByUser } = usePreferDrawerModeByUser();
   const { data: preferDrawerModeOnEditByUser } = usePreferDrawerModeOnEditByUser();
+  const { data: editorMode } = useEditorMode();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
 
   const condition = editorMode != null || preferDrawerModeByUser != null || preferDrawerModeOnEditByUser != null || isDeviceSmallerThanMd != null;
@@ -275,12 +277,17 @@ export const useDrawerMode = (): SWRResponse<boolean, Error> => {
     return isDeviceSmallerThanMd || preferDrawerMode;
   };
 
+  const isViewModeWithPreferDrawerMode = editorMode === EditorMode.View && preferDrawerModeByUser;
+  const isEditModeWithPreferDrawerMode = editorMode === EditorMode.Editor && preferDrawerModeOnEditByUser;
+  const useFallbackData = isViewModeWithPreferDrawerMode || isEditModeWithPreferDrawerMode;
+  const fallbackOption = useFallbackData
+    ? { fallbackData: true }
+    : { fallback: calcDrawerMode };
+
   return useSWRImmutable(
     condition ? ['isDrawerMode', editorMode, preferDrawerModeByUser, preferDrawerModeOnEditByUser, isDeviceSmallerThanMd] : null,
     calcDrawerMode,
-    {
-      fallback: calcDrawerMode,
-    },
+    fallbackOption,
   );
 };
 

+ 15 - 51
packages/app/src/styles/_wiki.scss

@@ -1,11 +1,10 @@
-div.body {
-  padding: 10px;
-}
+@use './variables' as var;
+@use './bootstrap/init' as bs;
 
 // hljs
-.wiki {
-  @import 'hljs';
-}
+// .wiki {
+//   @import 'hljs';
+// }
 
 .wiki {
   @mixin add-left-border($width) {
@@ -17,10 +16,13 @@ div.body {
     }
   }
 
-  @extend .text-break;
-
   font-size: 15px;
 
+  // @extend .text-break;
+  // https://github.com/twbs/bootstrap/blob/v4.6.1/scss/utilities/_text.scss#L65-L68
+  word-break: break-word !important; // Deprecated, but avoids issues with flex containers
+  word-wrap: break-word !important; // Used instead of `overflow-wrap` for IE & Edge Legacy
+
   h1,
   h2,
   h3,
@@ -82,7 +84,7 @@ div.body {
     padding: 0 20px;
     margin: 0 0 30px 0;
     font-size: 0.9em;
-    color: lighten($gray-800, 35%);
+    color: lighten(bs.$gray-800, 35%);
     border-left: 0.3rem solid #ddd;
   }
 
@@ -91,13 +93,6 @@ div.body {
     margin: 5px 0;
   }
 
-  img.emojione {
-    margin-top: -0.3em !important;
-    margin-bottom: 0 !important;
-    border: none;
-    box-shadow: none;
-  }
-
   ul,
   ol {
     padding-left: 30px;
@@ -108,7 +103,7 @@ div.body {
       line-height: 1.8em;
 
       pre {
-        line-height: $line-height-base;
+        line-height: bs.$line-height-base;
       }
     }
 
@@ -148,33 +143,6 @@ div.body {
     }
   }
 
-  .page-template-builder {
-    position: relative;
-
-    .template-create-button {
-      position: absolute;
-      top: 8px;
-      right: 8px;
-    }
-  }
-
-  .revision-head {
-    a {
-      text-decoration: none;
-    }
-
-    .revision-head-link,
-    .revision-head-edit-button {
-      margin-left: 0.5em;
-      font-size: 0.6em;
-      opacity: 0;
-    }
-    &:hover .revision-head-link,
-    &:hover .revision-head-edit-button {
-      opacity: 1 !important;
-    }
-  }
-
   $ratio: 0.95;
   &.comment {
     font-size: 14px;
@@ -213,19 +181,15 @@ div.body {
       font-size: 0.9em * $ratio;
     }
 
-    img.emojione {
-      margin-top: -0.3em * $ratio !important;
-    }
-
     ul,
     ol {
       padding-left: 15px;
       margin: 10px 0;
 
       li {
-        line-height: $line-height-base;
+        line-height: bs.$line-height-base;
         pre {
-          line-height: $line-height-base;
+          line-height: bs.$line-height-base;
         }
       }
     }
@@ -262,7 +226,7 @@ div.body {
   }
 }
 
-@include media-breakpoint-down(sm) {
+@include bs.media-breakpoint-down(sm) {
   .main .wiki {
     img {
       max-width: 100%;

+ 2 - 1
packages/app/src/styles/style-next.scss

@@ -12,6 +12,7 @@
 // icons
 @import '~simple-line-icons';
 @import '~material-icons/iconfont/filled';
+@import '~font-awesome';
 
 // // atoms
 @import 'atoms/buttons';
@@ -67,7 +68,7 @@
 // @import 'user';
 // @import 'staff_credit';
 // @import 'waves';
-// @import 'wiki';
+@import 'wiki';
 // @import 'sharelink';
 // @import 'linkedit-preview';
 

+ 6 - 6
packages/core/src/interfaces/page.ts

@@ -64,15 +64,15 @@ export type IPageInfo = {
   isDeletable: boolean,
   isAbleToDeleteCompletely: boolean,
   isRevertible: boolean,
-  contentAge?: number,
 }
 
 export type IPageInfoForEntity = IPageInfo & {
-  bookmarkCount?: number,
-  sumOfLikers?: number,
-  likerIds?: string[],
-  sumOfSeenUsers?: number,
-  seenUserIds?: string[],
+  bookmarkCount: number,
+  sumOfLikers: number,
+  likerIds: string[],
+  sumOfSeenUsers: number,
+  seenUserIds: string[],
+  contentAge: number,
 }
 
 export type IPageInfoForOperation = IPageInfoForEntity & {

+ 7 - 0
packages/core/src/utils/with-utils.ts

@@ -0,0 +1,7 @@
+import { SWRResponse } from 'swr';
+
+export type SWRResponseWithUtils<R extends SWRResponse, U> = R & U;
+
+export const withUtils = <R extends SWRResponse, U>(response: R, utils: U): SWRResponseWithUtils<R, U> => {
+  return Object.assign(response, utils);
+};

+ 5 - 0
yarn.lock

@@ -9495,6 +9495,11 @@ follow-redirects@^1.0.0, follow-redirects@^1.14.0, follow-redirects@^1.14.4:
   resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.7.tgz#2004c02eb9436eee9a21446a6477debf17e81685"
   integrity sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ==
 
+font-awesome@^4.7.0:
+  version "4.7.0"
+  resolved "https://registry.yarnpkg.com/font-awesome/-/font-awesome-4.7.0.tgz#8fa8cf0411a1a31afd07b06d2902bb9fc815a133"
+  integrity sha512-U6kGnykA/6bFmg1M/oT9EkFeIYv7JlX3bozwQJWiiLz6L0w3F5vBVPxHlwyX/vtNq1ckcpRKOB9f2Qal/VtFpg==
+
 for-each@^0.3.3:
   version "0.3.3"
   resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e"