Explorar o código

Resolved conflicts

Taichi Masuyama %!s(int64=3) %!d(string=hai) anos
pai
achega
58a714fcec
Modificáronse 66 ficheiros con 1117 adicións e 668 borrados
  1. 1 1
      lerna.json
  2. 1 1
      package.json
  3. 0 0
      packages/app/_obsolete/src/client/services/ContextExtractor.tsx
  4. 1 2
      packages/app/bin/github-actions/update-readme.sh
  5. 1 1
      packages/app/config/rate-limiter.ts
  6. 3 4
      packages/app/docker/README.md
  7. 7 7
      packages/app/package.json
  8. 1 0
      packages/app/public/static/locales/en_US/translation.json
  9. 1 0
      packages/app/public/static/locales/ja_JP/translation.json
  10. 1 0
      packages/app/public/static/locales/zh_CN/translation.json
  11. 10 4
      packages/app/src/components/ContentLinkButtons.tsx
  12. 26 25
      packages/app/src/components/Fab.tsx
  13. 72 12
      packages/app/src/components/InvitedForm.tsx
  14. 3 1
      packages/app/src/components/Layout/BasicLayout.tsx
  15. 1 1
      packages/app/src/components/Layout/ShareLinkLayout.tsx
  16. 66 43
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  17. 18 40
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  18. 11 6
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  19. 43 35
      packages/app/src/components/Page.tsx
  20. 6 4
      packages/app/src/components/Page/DisplaySwitcher.tsx
  21. 19 9
      packages/app/src/components/PageComment/CommentEditor.tsx
  22. 7 2
      packages/app/src/components/PageCreateModal.jsx
  23. 6 4
      packages/app/src/components/PageEditor.tsx
  24. 19 16
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  25. 0 172
      packages/app/src/components/PageEditor/DrawioModal.jsx
  26. 77 0
      packages/app/src/components/PageEditor/DrawioModal.tsx
  27. 8 2
      packages/app/src/components/PageEditor/Editor.tsx
  28. 80 48
      packages/app/src/components/PageEditorByHackmd.tsx
  29. 8 7
      packages/app/src/components/PasswordResetExecutionForm.tsx
  30. 13 12
      packages/app/src/components/PasswordResetRequestForm.tsx
  31. 2 8
      packages/app/src/components/SavePageControls.tsx
  32. 4 3
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  33. 2 2
      packages/app/src/components/Skelton.tsx
  34. 16 12
      packages/app/src/components/User/UserInfo.tsx
  35. 7 0
      packages/app/src/interfaces/errors/forgot-password.ts
  36. 0 7
      packages/app/src/interfaces/services/renderer.ts
  37. 25 23
      packages/app/src/pages/[[...path]].page.tsx
  38. 90 0
      packages/app/src/pages/forgot-password-errors.page.tsx
  39. 60 0
      packages/app/src/pages/forgot-password.page.tsx
  40. 1 1
      packages/app/src/pages/me/[[...path]].page.tsx
  41. 72 0
      packages/app/src/pages/reset-password.page.tsx
  42. 15 2
      packages/app/src/server/middlewares/inject-reset-order-by-token-middleware.ts
  43. 45 0
      packages/app/src/server/middlewares/invited-form-validator.ts
  44. 0 43
      packages/app/src/server/middlewares/login-form-validator.ts
  45. 2 0
      packages/app/src/server/routes/apiv3/index.js
  46. 53 0
      packages/app/src/server/routes/apiv3/invited.ts
  47. 41 14
      packages/app/src/server/routes/forgot-password.ts
  48. 5 4
      packages/app/src/server/routes/index.js
  49. 6 1
      packages/app/src/server/routes/login-passport.js
  50. 5 46
      packages/app/src/server/routes/login.js
  51. 3 1
      packages/app/src/server/routes/next.ts
  52. 7 7
      packages/app/src/stores/context.tsx
  53. 97 0
      packages/app/src/stores/modal.tsx
  54. 4 2
      packages/app/src/stores/page.tsx
  55. 8 6
      packages/app/src/stores/ui.tsx
  56. 3 0
      packages/app/src/styles/molecules/toastr.scss
  57. 2 1
      packages/app/src/styles/style-next.scss
  58. 15 0
      packages/app/src/utils/drawio-config.ts
  59. 1 1
      packages/codemirror-textlint/package.json
  60. 1 1
      packages/core/package.json
  61. 1 1
      packages/plugin-attachment-refs/package.json
  62. 6 11
      packages/plugin-lsx/package.json
  63. 1 1
      packages/remark-growi-plugin/package.json
  64. 1 1
      packages/slack/package.json
  65. 2 2
      packages/slackbot-proxy/package.json
  66. 4 8
      packages/ui/package.json

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
 {
   "npmClient": "yarn",
   "npmClient": "yarn",
   "useWorkspaces": true,
   "useWorkspaces": true,
-  "version": "5.1.5-RC.0",
+  "version": "6.0.0-RC.1",
   "packages": [
   "packages": [
     "packages/*"
     "packages/*"
   ]
   ]

+ 1 - 1
package.json

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

+ 0 - 0
packages/app/src/client/services/ContextExtractor.tsx → packages/app/_obsolete/src/client/services/ContextExtractor.tsx


+ 1 - 2
packages/app/bin/github-actions/update-readme.sh

@@ -2,5 +2,4 @@
 
 
 cd docker
 cd docker
 
 
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`5\.1\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/packages\/app\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}\2\3${RELEASED_VERSION}\4/" README.md
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`5\.1-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/packages\/app\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}-nocdn\2\3${RELEASED_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`6\.0\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/packages\/app\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}\2\3${RELEASED_VERSION}\4/" README.md

+ 1 - 1
packages/app/config/rate-limiter.ts

@@ -33,7 +33,7 @@ export const defaultConfig: IApiRateLimitEndpointMap = {
     maxRequests: MAX_REQUESTS_TIER_1,
     maxRequests: MAX_REQUESTS_TIER_1,
     usersPerIpProspection: 100,
     usersPerIpProspection: 100,
   },
   },
-  '/invited/activateInvited': {
+  '/invited': {
     method: 'POST',
     method: 'POST',
     maxRequests: MAX_REQUESTS_TIER_2,
     maxRequests: MAX_REQUESTS_TIER_2,
   },
   },

+ 3 - 4
packages/app/docker/README.md

@@ -10,10 +10,9 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 ------------------------------------------------
 
 
-* [`5.1.4`, `5.1`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.4/packages/app/docker/Dockerfile)
-* [`5.1.4-nocdn`, `5.1-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.4/packages/app/docker/Dockerfile)
-* [`5.0.11`, `5.0` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/packages/app/docker/Dockerfile)
-* [`5.0.11-nocdn`, `5.0-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/packages/app/docker/Dockerfile)
+* [`6.0.0`, `6.0`, `6`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v6.0.0/packages/app/docker/Dockerfile)
+* [`5.1.4`, `5.1`, `5`](https://github.com/weseek/growi/blob/v5.1.4/packages/app/docker/Dockerfile)
+* [`5.1.4-nocdn`, `5.1-nocdn`, `5-nocdn`](https://github.com/weseek/growi/blob/v5.1.4/packages/app/docker/Dockerfile)
 * [`4.5.23`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)
 * [`4.5.23`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)
 * [`4.5.23-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)
 * [`4.5.23-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)
 
 

+ 7 - 7
packages/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "5.1.5-RC.0",
+  "version": "6.0.0-RC.1",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
@@ -64,11 +64,11 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.1.5-RC.0",
-    "@growi/core": "^5.1.5-RC.0",
-    "@growi/plugin-attachment-refs": "^5.1.5-RC.0",
-    "@growi/plugin-lsx": "^5.1.5-RC.0",
-    "@growi/slack": "^5.1.5-RC.0",
+    "@growi/codemirror-textlint": "^6.0.0-RC.1",
+    "@growi/core": "^6.0.0-RC.1",
+    "@growi/plugin-attachment-refs": "^6.0.0-RC.1",
+    "@growi/plugin-lsx": "^6.0.0-RC.1",
+    "@growi/slack": "^6.0.0-RC.1",
     "@promster/express": "^7.0.2",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
     "@slack/events-api": "^3.0.0",
@@ -203,7 +203,7 @@
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.1.5-RC.0",
+    "@growi/ui": "^6.0.0-RC.1",
     "@handsontable/react": "=2.1.0",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^12.2.3",
     "@next/bundle-analyzer": "^12.2.3",

+ 1 - 0
packages/app/public/static/locales/en_US/translation.json

@@ -761,6 +761,7 @@
     "confirm_new_password": "Confirm the new password",
     "confirm_new_password": "Confirm the new password",
     "email_is_required": "Email is required",
     "email_is_required": "Email is required",
     "success_to_send_email": "Success to send email",
     "success_to_send_email": "Success to send email",
+    "feature_is_unavailable": "This feature is unavailable.",
     "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired. Please resend a password reset request via the link below.",
     "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired. Please resend a password reset request via the link below.",
     "password_and_confirm_password_does_not_match": "Password and confirm password does not match"
     "password_and_confirm_password_does_not_match": "Password and confirm password does not match"
   },
   },

+ 1 - 0
packages/app/public/static/locales/ja_JP/translation.json

@@ -752,6 +752,7 @@
     "confirm_new_password": "新しいパスワードの確認",
     "confirm_new_password": "新しいパスワードの確認",
     "email_is_required": "メールを入力してください",
     "email_is_required": "メールを入力してください",
     "success_to_send_email": "メールを送信しました",
     "success_to_send_email": "メールを送信しました",
+    "feature_is_unavailable": "この機能を利用することはできません。",
     "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。 以下のリンクからパスワードリセットリクエストを再送信してください。",
     "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。 以下のリンクからパスワードリセットリクエストを再送信してください。",
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
   },
   },

+ 1 - 0
packages/app/public/static/locales/zh_CN/translation.json

@@ -808,6 +808,7 @@
     "confirm_new_password": "确认新密码",
     "confirm_new_password": "确认新密码",
     "email_is_required": "电子邮件是必需的",
     "email_is_required": "电子邮件是必需的",
     "success_to_send_email": "我发了一封电子邮件",
     "success_to_send_email": "我发了一封电子邮件",
+    "feature_is_unavailable": "此功能不可用",
     "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。 请通过以下链接重新发送密码重置请求",
     "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。 请通过以下链接重新发送密码重置请求",
     "password_and_confirm_password_does_not_match": "密码和确认密码不匹配"
     "password_and_confirm_password_does_not_match": "密码和确认密码不匹配"
   },
   },

+ 10 - 4
packages/app/src/components/ContentLinkButtons.tsx

@@ -1,8 +1,9 @@
 import React, { useCallback } from 'react';
 import React, { useCallback } from 'react';
 
 
+import { IUserHasId } from '@growi/core';
+
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { RecentlyCreatedIcon } from '~/components/Icons/RecentlyCreatedIcon';
 import { RecentlyCreatedIcon } from '~/components/Icons/RecentlyCreatedIcon';
-import { usePageUser } from '~/stores/context';
 
 
 import styles from './ContentLinkButtons.module.scss';
 import styles from './ContentLinkButtons.module.scss';
 
 
@@ -52,11 +53,16 @@ const RecentlyCreatedLinkButton = React.memo(() => {
 
 
 RecentlyCreatedLinkButton.displayName = 'RecentlyCreatedLinkButton';
 RecentlyCreatedLinkButton.displayName = 'RecentlyCreatedLinkButton';
 
 
-export const ContentLinkButtons = (): JSX.Element => {
 
 
-  const { data: pageUser } = usePageUser();
+export type ContentLinkButtonsProps = {
+  author?: IUserHasId,
+}
+
+export const ContentLinkButtons = (props: ContentLinkButtonsProps): JSX.Element => {
+
+  const { author } = props;
 
 
-  if (pageUser == null || pageUser.status === 4) {
+  if (author == null || author.status === 4) {
     return <></>;
     return <></>;
   }
   }
 
 

+ 26 - 25
packages/app/src/components/Fab.tsx

@@ -31,32 +31,33 @@ export const Fab = (): JSX.Element => {
   useRipple(createBtnRef, { rippleColor: 'rgba(255, 255, 255, 0.3)' });
   useRipple(createBtnRef, { rippleColor: 'rgba(255, 255, 255, 0.3)' });
 
 
   /*
   /*
-  * Comment out to prevent err >>> TypeError: Cannot read properties of null (reading 'bottom')
+  * TODO: Comment out to prevent err >>> TypeError: Cannot read properties of null (reading 'bottom')
+  *       We need add style={{ position: 'relative }} to child elements if disable StickyEvents. see: use grep = "<Fab".
   */
   */
-  const stickyChangeHandler = useCallback((event) => {
-    logger.debug('StickyEvents.CHANGE detected');
-
-    const newAnimateClasses = event.detail.isSticky ? 'animated fadeInUp faster' : 'animated fadeOut faster';
-    const newButtonClasses = event.detail.isSticky ? '' : 'disabled grw-pointer-events-none';
-
-    setAnimateClasses(newAnimateClasses);
-    setButtonClasses(newButtonClasses);
-  }, []);
-
-  // setup effect by sticky event
-  useEffect(() => {
-    // sticky
-    // See: https://github.com/ryanwalters/sticky-events
-    const stickyEvents = new StickyEvents({ stickySelector: '#grw-fav-sticky-trigger' });
-    const { stickySelector } = stickyEvents;
-    const elem = document.querySelector(stickySelector);
-    elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-
-    // return clean up handler
-    return () => {
-      elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-    };
-  }, [stickyChangeHandler]);
+  // const stickyChangeHandler = useCallback((event) => {
+  //   logger.debug('StickyEvents.CHANGE detected');
+
+  //   const newAnimateClasses = event.detail.isSticky ? 'animated fadeInUp faster' : 'animated fadeOut faster';
+  //   const newButtonClasses = event.detail.isSticky ? '' : 'disabled grw-pointer-events-none';
+
+  //   setAnimateClasses(newAnimateClasses);
+  //   setButtonClasses(newButtonClasses);
+  // }, []);
+
+  // // setup effect by sticky event
+  // useEffect(() => {
+  //   // sticky
+  //   // See: https://github.com/ryanwalters/sticky-events
+  //   const stickyEvents = new StickyEvents({ stickySelector: '#grw-fav-sticky-trigger' });
+  //   const { stickySelector } = stickyEvents;
+  //   const elem = document.querySelector(stickySelector);
+  //   elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+
+  //   // return clean up handler
+  //   return () => {
+  //     elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+  //   };
+  // }, [stickyChangeHandler]);
 
 
   if (currentPath == null) {
   if (currentPath == null) {
     return <></>;
     return <></>;

+ 72 - 12
packages/app/src/components/InvitedForm.tsx

@@ -1,8 +1,12 @@
-import React from 'react';
+import React, { useCallback, useState } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+
+import { useCurrentUser } from '../stores/context';
 
 
-import { useCsrfToken, useCurrentUser } from '../stores/context';
 
 
 export type InvitedFormProps = {
 export type InvitedFormProps = {
   invitedFormUsername: string,
   invitedFormUsername: string,
@@ -10,23 +14,79 @@ export type InvitedFormProps = {
 }
 }
 
 
 export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
 export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
+
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { data: csrfToken } = useCsrfToken();
+  const router = useRouter();
   const { data: user } = useCurrentUser();
   const { data: user } = useCurrentUser();
+  const [isConnectSuccess, setIsConnectSuccess] = useState<boolean>(false);
+  const [loginErrors, setLoginErrors] = useState<Error[]>([]);
 
 
   const { invitedFormUsername, invitedFormName } = props;
   const { invitedFormUsername, invitedFormName } = props;
 
 
+  const submitHandler = useCallback(async(e) => {
+    e.preventDefault();
+
+    const formData = e.target.elements;
+
+    const {
+      'invitedForm[name]': { value: name },
+      'invitedForm[password]': { value: password },
+      'invitedForm[username]': { value: username },
+    } = formData;
+
+    const invitedForm = {
+      name,
+      password,
+      username,
+    };
+
+    try {
+      const res = await apiv3Post('/invited', { invitedForm });
+      setIsConnectSuccess(true);
+      const { redirectTo } = res.data;
+      router.push(redirectTo);
+    }
+    catch (err) {
+      setLoginErrors(err);
+    }
+  }, [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 ? (
+          <p className="alert alert-danger">
+            { loginErrors.map((err, index) => {
+              return <span key={index}>{ t(err.message) }<br/></span>;
+            }) }
+          </p>
+        ) : (
+          <p className="alert alert-success">
+            <strong>{ t('invited.discription_heading') }</strong><br></br>
+            <small>{ t('invited.discription') }</small>
+          </p>
+        ) }
+      </>
+    );
+  }, [isConnectSuccess, loginErrors, t]);
+
   if (user == null) {
   if (user == null) {
     return <></>;
     return <></>;
   }
   }
 
 
   return (
   return (
-    <div className="noLogin-dialog p-3 mx-auto" id="noLogin-dialog">
-      <p className="alert alert-success">
-        <strong>{ t('invited.discription_heading') }</strong><br></br>
-        <small>{ t('invited.discription') }</small>
-      </p>
-      <form role="form" action="/invited/activateInvited" method="post" id="invited-form">
+    <div className="noLogin-dialog px-3 pb-3 mx-auto" id="noLogin-dialog">
+      { formNotification() }
+      <form role="form" onSubmit={submitHandler} id="invited-form">
         {/* Email Form */}
         {/* Email Form */}
         <div className="input-group">
         <div className="input-group">
           <div className="input-group-prepend">
           <div className="input-group-prepend">
@@ -89,11 +149,11 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
             placeholder={t('Password')}
             placeholder={t('Password')}
             name="invitedForm[password]"
             name="invitedForm[password]"
             required
             required
+            minLength={6}
           />
           />
         </div>
         </div>
         {/* Create Button */}
         {/* Create Button */}
-        <div className="input-group justify-content-center d-flex mt-5">
-          <input type="hidden" name="_csrf" value={csrfToken} />
+        <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">
             <div className="eff"></div>
             <div className="eff"></div>
             <span className="btn-label"><i className="icon-user-follow"></i></span>
             <span className="btn-label"><i className="icon-user-follow"></i></span>
@@ -101,7 +161,7 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
           </button>
           </button>
         </div>
         </div>
       </form>
       </form>
-      <div className="input-group mt-5 d-flex justify-content-center">
+      <div className="input-group mt-4 d-flex justify-content-center">
         <a href="https://growi.org" className="link-growi-org">
         <a href="https://growi.org" className="link-growi-org">
           <span className="growi">GROWI</span>.<span className="org">ORG</span>
           <span className="growi">GROWI</span>.<span className="org">ORG</span>
         </a>
         </a>

+ 3 - 1
packages/app/src/components/Layout/BasicLayout.tsx

@@ -19,6 +19,7 @@ const PageDeleteModal = dynamic(() => import('../PageDeleteModal'), { ssr: false
 const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false });
 const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false });
 const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
 const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
 const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal'), { ssr: false });
 const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal'), { ssr: false });
+const DrawioModal = dynamic(() => import('../PageEditor/DrawioModal').then(mod => mod.DrawioModal), { ssr: false });
 // Fab
 // Fab
 const Fab = dynamic(() => import('../Fab').then(mod => mod.Fab), { ssr: false });
 const Fab = dynamic(() => import('../Fab').then(mod => mod.Fab), { ssr: false });
 
 
@@ -45,7 +46,7 @@ export const BasicLayout = ({
           <Sidebar />
           <Sidebar />
         </div>
         </div>
 
 
-        <div className="flex-fill mw-0">
+        <div className="flex-fill mw-0" style={{ position: 'relative' }}>
           {children}
           {children}
         </div>
         </div>
       </div>
       </div>
@@ -58,6 +59,7 @@ export const BasicLayout = ({
       <PageRenameModal />
       <PageRenameModal />
       <PagePresentationModal />
       <PagePresentationModal />
       <PageAccessoriesModal />
       <PageAccessoriesModal />
+      <DrawioModal />
       <HotkeysManager />
       <HotkeysManager />
 
 
       <Fab />
       <Fab />

+ 1 - 1
packages/app/src/components/Layout/ShareLinkLayout.tsx

@@ -33,7 +33,7 @@ export const ShareLinkLayout = ({
       <GrowiNavbar />
       <GrowiNavbar />
 
 
       <div className="page-wrapper d-flex d-print-block">
       <div className="page-wrapper d-flex d-print-block">
-        <div className="flex-fill mw-0">
+        <div className="flex-fill mw-0" style={{ position: 'relative' }}>
           {children}
           {children}
         </div>
         </div>
       </div>
       </div>

+ 66 - 43
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -1,6 +1,6 @@
 import React, { useState, useEffect, useCallback } from 'react';
 import React, { useState, useEffect, useCallback } from 'react';
 
 
-import { isPopulated } from '@growi/core';
+import { isPopulated, IUser } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import { DropdownItem } from 'reactstrap';
 import { DropdownItem } from 'reactstrap';
@@ -9,13 +9,13 @@ import { exportAsMarkdown } from '~/client/services/page-operation';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiPost } from '~/client/util/apiv1-client';
 import {
 import {
-  IPageToRenameWithMeta, IPageWithMeta, IPageInfoForEntity, IPageHasId,
+  IPageToRenameWithMeta, IPageWithMeta, IPageInfoForEntity,
 } from '~/interfaces/page';
 } from '~/interfaces/page';
 import { IResTagsUpdateApiv1 } from '~/interfaces/tag';
 import { IResTagsUpdateApiv1 } from '~/interfaces/tag';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import {
 import {
-  useCurrentPageId,
-  useCurrentPathname, useIsNotFound,
+  useCurrentPageId, useCurrentPathname,
+  useIsNotFound,
   useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId, useTemplateTagData,
   useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId, useTemplateTagData,
 } from '~/stores/context';
 } from '~/stores/context';
 import { usePageTagsForEditors } from '~/stores/editor';
 import { usePageTagsForEditors } from '~/stores/editor';
@@ -39,9 +39,13 @@ import { Skelton } from '../Skelton';
 import { GrowiSubNavigation } from './GrowiSubNavigation';
 import { GrowiSubNavigation } from './GrowiSubNavigation';
 import { SubNavButtonsProps } from './SubNavButtons';
 import { SubNavButtonsProps } from './SubNavButtons';
 
 
+import AuthorInfoStyles from './AuthorInfo.module.scss';
 import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
 import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
 
 
 
 
+const AuthorInfoSkelton = () => <Skelton additionalClass={`${AuthorInfoStyles['grw-author-info-skelton']} py-1`} />;
+
+
 const PageEditorModeManager = dynamic(
 const PageEditorModeManager = dynamic(
   () => import('./PageEditorModeManager'),
   () => import('./PageEditorModeManager'),
   { ssr: false, loading: () => <Skelton additionalClass={`${PageEditorModeManagerStyles['grw-page-editor-mode-manager-skelton']}`} /> },
   { ssr: false, loading: () => <Skelton additionalClass={`${PageEditorModeManagerStyles['grw-page-editor-mode-manager-skelton']}`} /> },
@@ -52,7 +56,10 @@ const SubNavButtons = dynamic<SubNavButtonsProps>(
   () => import('./SubNavButtons').then(mod => mod.SubNavButtons),
   () => import('./SubNavButtons').then(mod => mod.SubNavButtons),
   { ssr: false, loading: () => <></> },
   { ssr: false, loading: () => <></> },
 );
 );
-
+const AuthorInfo = dynamic(() => import('./AuthorInfo'), {
+  ssr: false,
+  loading: AuthorInfoSkelton,
+});
 
 
 type AdditionalMenuItemsProps = {
 type AdditionalMenuItemsProps = {
   pageId: string,
   pageId: string,
@@ -178,9 +185,9 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { data: pageId } = useCurrentPageId();
   const { data: pageId } = useCurrentPageId();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
+  const { data: isNotFound } = useIsNotFound();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isSharedUser } = useIsSharedUser();
-  const { data: isNotFound } = useIsNotFound();
   const { data: shareLinkId } = useShareLinkId();
   const { data: shareLinkId } = useShareLinkId();
 
 
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
@@ -296,7 +303,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   }, []);
   }, []);
 
 
 
 
-  const ControlComponents = useCallback(() => {
+  const RightComponent = useCallback(() => {
     const additionalMenuItemsRenderer = () => {
     const additionalMenuItemsRenderer = () => {
       if (revisionId == null || pageId == null) {
       if (revisionId == null || pageId == null) {
         return <></>;
         return <></>;
@@ -313,34 +320,53 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
 
     return (
     return (
       <>
       <>
-        <div className="d-flex flex-column align-items-end justify-content-center py-md-2" style={{ gap: `${isCompactMode ? '5px' : '7px'}` }}>
-          { isViewMode && (
-            <div className="h-50 w-100">
-              { pageId != null && (
-                <SubNavButtons
-                  isCompactMode={isCompactMode}
-                  pageId={pageId}
-                  revisionId={revisionId}
-                  shareLinkId={shareLinkId}
-                  path={path}
-                  disableSeenUserInfoPopover={isSharedUser}
-                  showPageControlDropdown={isAbleToShowPageManagement}
-                  additionalMenuItemRenderer={additionalMenuItemsRenderer}
-                  onClickDuplicateMenuItem={duplicateItemClickedHandler}
-                  onClickRenameMenuItem={renameItemClickedHandler}
-                  onClickDeleteMenuItem={deleteItemClickedHandler}
-                />
-              ) }
-            </div>
+        <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 && (
+              <div className="h-50">
+                { pageId != null && (
+                  <SubNavButtons
+                    isCompactMode={isCompactMode}
+                    pageId={pageId}
+                    revisionId={revisionId}
+                    shareLinkId={shareLinkId}
+                    path={path}
+                    disableSeenUserInfoPopover={isSharedUser}
+                    showPageControlDropdown={isAbleToShowPageManagement}
+                    additionalMenuItemRenderer={additionalMenuItemsRenderer}
+                    onClickDuplicateMenuItem={duplicateItemClickedHandler}
+                    onClickRenameMenuItem={renameItemClickedHandler}
+                    onClickDeleteMenuItem={deleteItemClickedHandler}
+                  />
+                ) }
+              </div>
+            ) }
+            {isAbleToShowPageEditorModeManager && (
+              <PageEditorModeManager
+                onPageEditorModeButtonClicked={viewType => mutateEditorMode(viewType)}
+                isBtnDisabled={isGuestUser}
+                editorMode={editorMode}
+              />
+            )}
+          </div>
+          { (isAbleToShowPageAuthors && !isCompactMode) && (
+            <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
+                  ? <AuthorInfo user={currentPage.creator as IUser} date={currentPage.createdAt} locate="subnav" />
+                  : <AuthorInfoSkelton />
+                }
+              </li>
+              <li className="mt-1 pt-1 border-top">
+                { currentPage != null
+                  ? <AuthorInfo user={currentPage.lastUpdateUser as IUser} date={currentPage.updatedAt} mode="update" locate="subnav" />
+                  : <AuthorInfoSkelton />
+                }
+              </li>
+            </ul>
           ) }
           ) }
-          {isAbleToShowPageEditorModeManager && (
-            <PageEditorModeManager
-              onPageEditorModeButtonClicked={viewType => mutateEditorMode(viewType)}
-              isBtnDisabled={isGuestUser}
-              editorMode={editorMode}
-            />
-          )}
         </div>
         </div>
+
         {path != null && currentUser != null && (
         {path != null && currentUser != null && (
           <CreateTemplateModal
           <CreateTemplateModal
             path={path}
             path={path}
@@ -351,28 +377,25 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       </>
       </>
     );
     );
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  }, [currentUser, pageId, revisionId, shareLinkId, path, editorMode, isCompactMode, isViewMode, isSharedUser, isAbleToShowPageManagement, isAbleToShowPageEditorModeManager, isLinkSharingDisabled, isGuestUser, isPageTemplateModalShown, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, mutateEditorMode, templateMenuItemClickHandler]);
+  }, [isCompactMode, isViewMode, pageId, revisionId, shareLinkId, path, isSharedUser, isAbleToShowPageManagement, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, isAbleToShowPageEditorModeManager, isGuestUser, editorMode, isAbleToShowPageAuthors, currentPage, currentUser, isPageTemplateModalShown, isLinkSharingDisabled, templateMenuItemClickHandler, mutateEditorMode]);
 
 
-  if (currentPathname == null) {
-    return <></>;
-  }
 
 
-  const notFoundPage: Partial<IPageHasId> = {
-    path: currentPathname,
-  };
+  const pagePath = isNotFound
+    ? currentPathname
+    : currentPage?.path;
 
 
   return (
   return (
     <GrowiSubNavigation
     <GrowiSubNavigation
-      page={currentPage ?? notFoundPage}
+      pagePath={pagePath}
+      pageId={currentPage?._id}
       showDrawerToggler={isDrawerMode}
       showDrawerToggler={isDrawerMode}
       showTagLabel={isAbleToShowTagLabel}
       showTagLabel={isAbleToShowTagLabel}
-      showPageAuthors={isAbleToShowPageAuthors}
       isGuestUser={isGuestUser}
       isGuestUser={isGuestUser}
       isDrawerMode={isDrawerMode}
       isDrawerMode={isDrawerMode}
       isCompactMode={isCompactMode}
       isCompactMode={isCompactMode}
       tags={isViewMode ? tagsInfoData?.tags : tagsForEditors}
       tags={isViewMode ? tagsInfoData?.tags : tagsForEditors}
       tagsUpdatedHandler={isViewMode ? tagsUpdatedHandlerForViewMode : tagsUpdatedHandlerForEditMode}
       tagsUpdatedHandler={isViewMode ? tagsUpdatedHandlerForViewMode : tagsUpdatedHandlerForEditMode}
-      controls={ControlComponents}
+      rightComponent={RightComponent}
       additionalClasses={['container-fluid']}
       additionalClasses={['container-fluid']}
     />
     />
   );
   );

+ 18 - 40
packages/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -2,42 +2,37 @@ import React from 'react';
 
 
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 
 
-import { IPageHasId } from '~/interfaces/page';
-import { IUser } from '~/interfaces/user';
 import {
 import {
   EditorMode, useEditorMode,
   EditorMode, useEditorMode,
 } from '~/stores/ui';
 } from '~/stores/ui';
 
 
 import { TagLabelsSkelton } from '../Page/TagLabels';
 import { TagLabelsSkelton } from '../Page/TagLabels';
 import PagePathNav from '../PagePathNav';
 import PagePathNav from '../PagePathNav';
-import { Skelton } from '../Skelton';
 
 
 import DrawerToggler from './DrawerToggler';
 import DrawerToggler from './DrawerToggler';
 
 
-import AuthorInfoStyles from './AuthorInfo.module.scss';
+
 import styles from './GrowiSubNavigation.module.scss';
 import styles from './GrowiSubNavigation.module.scss';
 
 
+
 const TagLabels = dynamic(() => import('../Page/TagLabels').then(mod => mod.TagLabels), {
 const TagLabels = dynamic(() => import('../Page/TagLabels').then(mod => mod.TagLabels), {
   ssr: false,
   ssr: false,
-  loading: () => <TagLabelsSkelton />,
-});
-const AuthorInfo = dynamic(() => import('./AuthorInfo'), {
-  ssr: false,
-  loading: () => <Skelton additionalClass={`${AuthorInfoStyles['grw-author-info-skelton']} py-1`} />,
+  loading: TagLabelsSkelton,
 });
 });
 
 
 
 
 export type GrowiSubNavigationProps = {
 export type GrowiSubNavigationProps = {
-  page: Partial<IPageHasId>,
+  pagePath?: string,
+  pageId?: string,
+  isNotFound?: boolean,
   showDrawerToggler?: boolean,
   showDrawerToggler?: boolean,
   showTagLabel?: boolean,
   showTagLabel?: boolean,
-  showPageAuthors?: boolean,
   isGuestUser?: boolean,
   isGuestUser?: boolean,
   isDrawerMode?: boolean,
   isDrawerMode?: boolean,
   isCompactMode?: boolean,
   isCompactMode?: boolean,
   tags?: string[],
   tags?: string[],
   tagsUpdatedHandler?: (newTags: string[]) => Promise<void> | void,
   tagsUpdatedHandler?: (newTags: string[]) => Promise<void> | void,
-  controls: React.FunctionComponent,
+  rightComponent: React.FunctionComponent,
   additionalClasses?: string[],
   additionalClasses?: string[],
 }
 }
 
 
@@ -46,11 +41,11 @@ export const GrowiSubNavigation = (props: GrowiSubNavigationProps): JSX.Element
   const { data: editorMode } = useEditorMode();
   const { data: editorMode } = useEditorMode();
 
 
   const {
   const {
-    page,
-    showDrawerToggler, showTagLabel, showPageAuthors,
+    pageId, pagePath,
+    showDrawerToggler, showTagLabel,
     isGuestUser, isDrawerMode, isCompactMode,
     isGuestUser, isDrawerMode, isCompactMode,
     tags, tagsUpdatedHandler,
     tags, tagsUpdatedHandler,
-    controls: Controls,
+    rightComponent: RightComponent,
     additionalClasses = [],
     additionalClasses = [],
   } = props;
   } = props;
 
 
@@ -58,15 +53,6 @@ export const GrowiSubNavigation = (props: GrowiSubNavigationProps): JSX.Element
   const isEditorMode = !isViewMode;
   const isEditorMode = !isViewMode;
   const compactModeClasses = isCompactMode ? 'grw-subnav-compact d-print-none' : '';
   const compactModeClasses = isCompactMode ? 'grw-subnav-compact d-print-none' : '';
 
 
-  const {
-    _id: pageId, path, creator, lastUpdateUser,
-    createdAt, updatedAt,
-  } = page;
-
-  if (path == null) {
-    return <></>;
-  }
-
   return (
   return (
     <div className={`grw-subnav ${styles['grw-subnav']} d-flex align-items-center justify-content-between ${additionalClasses.join(' ')}
     <div className={`grw-subnav ${styles['grw-subnav']} d-flex align-items-center justify-content-between ${additionalClasses.join(' ')}
     ${compactModeClasses}`} >
     ${compactModeClasses}`} >
@@ -80,27 +66,19 @@ export const GrowiSubNavigation = (props: GrowiSubNavigationProps): JSX.Element
         <div className="grw-path-nav-container">
         <div className="grw-path-nav-container">
           { (showTagLabel && !isCompactMode) && (
           { (showTagLabel && !isCompactMode) && (
             <div className="grw-taglabels-container">
             <div className="grw-taglabels-container">
-              <TagLabels tags={tags} isGuestUser={isGuestUser ?? false} tagsUpdateInvoked={tagsUpdatedHandler} />
+              { tags != null
+                ? <TagLabels tags={tags} isGuestUser={isGuestUser ?? false} tagsUpdateInvoked={tagsUpdatedHandler} />
+                : <TagLabelsSkelton />
+              }
             </div>
             </div>
           ) }
           ) }
-          <PagePathNav pageId={pageId} pagePath={path} isSingleLineMode={isEditorMode} isCompactMode={isCompactMode} />
+          { pagePath != null && (
+            <PagePathNav pageId={pageId} pagePath={pagePath} isSingleLineMode={isEditorMode} isCompactMode={isCompactMode} />
+          ) }
         </div>
         </div>
       </div>
       </div>
       {/* Right side. */}
       {/* Right side. */}
-      <div className="d-flex">
-        <Controls />
-        {/* Page Authors */}
-        { (showPageAuthors && !isCompactMode) && (
-          <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">
-              <AuthorInfo user={creator as IUser} date={createdAt} locate="subnav" />
-            </li>
-            <li className="mt-1 pt-1 border-top">
-              <AuthorInfo user={lastUpdateUser as IUser} date={updatedAt} mode="update" locate="subnav" />
-            </li>
-          </ul>
-        ) }
-      </div>
+      <RightComponent />
     </div>
     </div>
   );
   );
 };
 };

+ 11 - 6
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -2,6 +2,7 @@ import React, { useRef } from 'react';
 
 
 import { UserPicture } from '@growi/ui';
 import { UserPicture } from '@growi/ui';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 import { useRipple } from 'react-use-ripple';
 import { useRipple } from 'react-use-ripple';
 
 
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
@@ -53,12 +54,16 @@ const PersonalDropdown = () => {
           </div>
           </div>
 
 
           <div className="btn-group btn-block mt-2" role="group">
           <div className="btn-group btn-block mt-2" role="group">
-            <a className="btn btn-sm btn-outline-secondary col" href={`/user/${user.username}`}>
-              <i className="icon-fw icon-home"></i>{ t('personal_dropdown.home') }
-            </a>
-            <a className="btn btn-sm btn-outline-secondary col" href="/me">
-              <i className="icon-fw icon-wrench"></i>{ t('personal_dropdown.settings') }
-            </a>
+            <Link href={`/user/${user.username}`}>
+              <a className="btn btn-sm btn-outline-secondary col">
+                <i className="icon-fw icon-home"></i>{ t('personal_dropdown.home') }
+              </a>
+            </Link>
+            <Link href="/me">
+              <a className="btn btn-sm btn-outline-secondary col">
+                <i className="icon-fw icon-wrench"></i>{ t('personal_dropdown.settings') }
+              </a>
+            </Link>
           </div>
           </div>
         </div>
         </div>
 
 

+ 43 - 35
packages/app/src/components/Page.tsx

@@ -3,12 +3,14 @@ import React, {
   useEffect, useRef, useState,
   useEffect, useRef, useState,
 } from 'react';
 } from 'react';
 
 
+import EventEmitter from 'events';
+
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 // import { debounce } from 'throttle-debounce';
 // import { debounce } from 'throttle-debounce';
 
 
 import { HtmlElementNode } from 'rehype-toc';
 import { HtmlElementNode } from 'rehype-toc';
 
 
-// import { getOptionsToSave } from '~/client/util/editor';
+import { getOptionsToSave } from '~/client/util/editor';
 import {
 import {
   useIsGuestUser, useCurrentPageTocNode, useShareLinkId,
   useIsGuestUser, useCurrentPageTocNode, useShareLinkId,
 } from '~/stores/context';
 } from '~/stores/context';
@@ -23,11 +25,13 @@ import {
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import RevisionRenderer from './Page/RevisionRenderer';
 import RevisionRenderer from './Page/RevisionRenderer';
-
-// TODO: import dynamically
+import { DrawioModal } from './PageEditor/DrawioModal';
 // import MarkdownTable from '~/client/models/MarkdownTable';
 // import MarkdownTable from '~/client/models/MarkdownTable';
-// import mdu from './PageEditor/MarkdownDrawioUtil';
-// import mtu from './PageEditor/MarkdownTableUtil';
+import mdu from './PageEditor/MarkdownDrawioUtil';
+import mtu from './PageEditor/MarkdownTableUtil';
+
+
+declare const globalEmitter: EventEmitter;
 
 
 // const DrawioModal = dynamic(() => import('./PageEditor/DrawioModal'), { ssr: false });
 // const DrawioModal = dynamic(() => import('./PageEditor/DrawioModal'), { ssr: false });
 const GridEditModal = dynamic(() => import('./PageEditor/GridEditModal'), { ssr: false });
 const GridEditModal = dynamic(() => import('./PageEditor/GridEditModal'), { ssr: false });
@@ -132,35 +136,35 @@ class PageSubstance extends React.Component<PageSubstanceProps> {
   }
   }
 
 
   async saveHandlerForDrawioModal(drawioData) {
   async saveHandlerForDrawioModal(drawioData) {
-    // const {
-    //   isSlackEnabled, slackChannels, pageContainer, pageTags, grant, grantGroupId, grantGroupName, mutateIsEnabledUnsavedWarning,
-    // } = this.props;
-    // const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
-
-    // const newMarkdown = mdu.replaceDrawioInMarkdown(
-    //   drawioData,
-    //   this.props.pageContainer.state.markdown,
-    //   this.state.currentTargetDrawioArea.beginLineNumber,
-    //   this.state.currentTargetDrawioArea.endLineNumber,
-    // );
-
-    // try {
-    //   // disable unsaved warning
-    //   mutateIsEnabledUnsavedWarning(false);
-
-    //   // eslint-disable-next-line no-unused-vars
-    //   const { page, tags } = await pageContainer.save(newMarkdown, this.props.editorMode, optionsToSave);
-    //   logger.debug('success to save');
-
-    //   pageContainer.showSuccessToastr();
-    // }
-    // catch (error) {
-    //   logger.error('failed to save', error);
-    //   pageContainer.showErrorToastr(error);
-    // }
-    // finally {
-    //   this.setState({ currentTargetDrawioArea: null });
-    // }
+  //   const {
+  //     isSlackEnabled, slackChannels, pageContainer, pageTags, grant, grantGroupId, grantGroupName, mutateIsEnabledUnsavedWarning,
+  //   } = this.props;
+  //   const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
+
+    //   const newMarkdown = mdu.replaceDrawioInMarkdown(
+    //     drawioData,
+    //     this.props.pageContainer.state.markdown,
+    //     this.state.currentTargetDrawioArea.beginLineNumber,
+    //     this.state.currentTargetDrawioArea.endLineNumber,
+    //   );
+
+    //   try {
+    //     // disable unsaved warning
+    //     mutateIsEnabledUnsavedWarning(false);
+
+    //     // eslint-disable-next-line no-unused-vars
+    //     const { page, tags } = await pageContainer.save(newMarkdown, this.props.editorMode, optionsToSave);
+    //     logger.debug('success to save');
+
+  //     pageContainer.showSuccessToastr();
+  //   }
+  //   catch (error) {
+  //     logger.error('failed to save', error);
+  //     pageContainer.showErrorToastr(error);
+  //   }
+  //   finally {
+  //     this.setState({ currentTargetDrawioArea: null });
+  //   }
   }
   }
 
 
   override render() {
   override render() {
@@ -182,7 +186,11 @@ class PageSubstance extends React.Component<PageSubstanceProps> {
             <GridEditModal ref={this.gridEditModal} />
             <GridEditModal ref={this.gridEditModal} />
             <LinkEditModal ref={this.linkEditModal} />
             <LinkEditModal ref={this.linkEditModal} />
             {/* <HandsontableModal ref={this.handsontableModal} onSave={this.saveHandlerForHandsontableModal} /> */}
             {/* <HandsontableModal ref={this.handsontableModal} onSave={this.saveHandlerForHandsontableModal} /> */}
-            {/* <DrawioModal ref={this.drawioModal} onSave={this.saveHandlerForDrawioModal} /> */}
+            {/* TODO: use global DrawioModal https://redmine.weseek.co.jp/issues/105981 */}
+            {/* <DrawioModal
+              ref={this.drawioModal}
+              onSave={this.saveHandlerForDrawioModal}
+            /> */}
           </>
           </>
         )}
         )}
       </div>
       </div>

+ 6 - 4
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -13,10 +13,12 @@ import { useSWRxCurrentPage } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 
 
 import CountBadge from '../Common/CountBadge';
 import CountBadge from '../Common/CountBadge';
+import { ContentLinkButtonsProps } from '../ContentLinkButtons';
 import CustomTabContent from '../CustomNavigation/CustomTabContent';
 import CustomTabContent from '../CustomNavigation/CustomTabContent';
 import PageListIcon from '../Icons/PageListIcon';
 import PageListIcon from '../Icons/PageListIcon';
 import { Page } from '../Page';
 import { Page } from '../Page';
 import TableOfContents from '../TableOfContents';
 import TableOfContents from '../TableOfContents';
+import { UserInfoProps } from '../User/UserInfo';
 
 
 import styles from './DisplaySwitcher.module.scss';
 import styles from './DisplaySwitcher.module.scss';
 
 
@@ -27,9 +29,9 @@ const PageEditor = dynamic(() => import('../PageEditor'), { ssr: false });
 const PageEditorByHackmd = dynamic(() => import('../PageEditorByHackmd').then(mod => mod.PageEditorByHackmd), { ssr: false });
 const PageEditorByHackmd = dynamic(() => import('../PageEditorByHackmd').then(mod => mod.PageEditorByHackmd), { ssr: false });
 const EditorNavbarBottom = dynamic(() => import('../PageEditor/EditorNavbarBottom'), { ssr: false });
 const EditorNavbarBottom = dynamic(() => import('../PageEditor/EditorNavbarBottom'), { ssr: false });
 const HashChanged = dynamic(() => import('../EventListeneres/HashChanged'), { ssr: false });
 const HashChanged = dynamic(() => import('../EventListeneres/HashChanged'), { ssr: false });
-const ContentLinkButtons = dynamic(() => import('../ContentLinkButtons').then(mod => mod.ContentLinkButtons), { ssr: false });
+const ContentLinkButtons = dynamic<ContentLinkButtonsProps>(() => import('../ContentLinkButtons').then(mod => mod.ContentLinkButtons), { ssr: false });
 const NotFoundPage = dynamic(() => import('../NotFoundPage'), { ssr: false });
 const NotFoundPage = dynamic(() => import('../NotFoundPage'), { ssr: false });
-const UserInfo = dynamic(() => import('../User/UserInfo').then(mod => mod.UserInfo), { ssr: false });
+const UserInfo = dynamic<UserInfoProps>(() => import('../User/UserInfo').then(mod => mod.UserInfo), { ssr: false });
 
 
 
 
 const PageView = React.memo((): JSX.Element => {
 const PageView = React.memo((): JSX.Element => {
@@ -49,7 +51,7 @@ const PageView = React.memo((): JSX.Element => {
     <div className="d-flex flex-column flex-lg-row">
     <div className="d-flex flex-column flex-lg-row">
 
 
       <div className="flex-grow-1 flex-basis-0 mw-0">
       <div className="flex-grow-1 flex-basis-0 mw-0">
-        { isUsersHomePagePath && <UserInfo /> }
+        { isUsersHomePagePath && <UserInfo author={currentPage?.creator} /> }
         { !isNotFound && <Page /> }
         { !isNotFound && <Page /> }
         { isNotFound && <NotFoundPage /> }
         { isNotFound && <NotFoundPage /> }
       </div>
       </div>
@@ -94,7 +96,7 @@ const PageView = React.memo((): JSX.Element => {
 
 
             <div className="d-none d-lg-block">
             <div className="d-none d-lg-block">
               <TableOfContents />
               <TableOfContents />
-              { isUsersHomePagePath && <ContentLinkButtons /> }
+              { isUsersHomePagePath && <ContentLinkButtons author={currentPage?.creator} /> }
             </div>
             </div>
 
 
           </div>
           </div>

+ 19 - 9
packages/app/src/components/PageComment/CommentEditor.tsx

@@ -81,7 +81,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
   const [comment, setComment] = useState(commentBody ?? '');
   const [comment, setComment] = useState(commentBody ?? '');
   const [activeTab, setActiveTab] = useState('comment_editor');
   const [activeTab, setActiveTab] = useState('comment_editor');
   const [error, setError] = useState();
   const [error, setError] = useState();
-  const [slackChannels, setSlackChannels] = useState(slackChannelsData?.toString());
+  const [slackChannels, setSlackChannels] = useState<string>('');
 
 
   const editorRef = useRef<IEditorMethods>(null);
   const editorRef = useRef<IEditorMethods>(null);
 
 
@@ -90,9 +90,19 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
   }, []);
   }, []);
 
 
   useEffect(() => {
   useEffect(() => {
-    if (slackChannels === undefined) { return }
-    setSlackChannels(slackChannelsData?.toString());
-  }, [slackChannelsData, slackChannels]);
+    if (slackChannelsData != null) {
+      setSlackChannels(slackChannelsData.toString());
+      mutateIsSlackEnabled(false);
+    }
+  }, [mutateIsSlackEnabled, slackChannelsData]);
+
+  const isSlackEnabledToggleHandler = (isSlackEnabled: boolean) => {
+    mutateIsSlackEnabled(isSlackEnabled, false);
+  };
+
+  const slackChannelsChangedHandler = useCallback((slackChannels: string) => {
+    setSlackChannels(slackChannels);
+  }, []);
 
 
   const initializeEditor = useCallback(() => {
   const initializeEditor = useCallback(() => {
     setComment('');
     setComment('');
@@ -289,14 +299,14 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
             <span className="flex-grow-1" />
             <span className="flex-grow-1" />
             <span className="d-none d-sm-inline">{ errorMessage && errorMessage }</span>
             <span className="d-none d-sm-inline">{ errorMessage && errorMessage }</span>
 
 
-            { isSlackConfigured
+            { isSlackConfigured && isSlackEnabled != null
               && (
               && (
                 <div className="form-inline align-self-center mr-md-2">
                 <div className="form-inline align-self-center mr-md-2">
                   <SlackNotification
                   <SlackNotification
-                    isSlackEnabled
-                    slackChannels={slackChannelsData?.toString() ?? ''}
-                    onEnabledFlagChange={isSlackEnabled => mutateIsSlackEnabled(isSlackEnabled, false)}
-                    onChannelChange={setSlackChannels}
+                    isSlackEnabled={isSlackEnabled}
+                    slackChannels={slackChannels}
+                    onEnabledFlagChange={isSlackEnabledToggleHandler}
+                    onChannelChange={slackChannelsChangedHandler}
                     id="idForComment"
                     id="idForComment"
                   />
                   />
                 </div>
                 </div>

+ 7 - 2
packages/app/src/components/PageCreateModal.jsx

@@ -5,6 +5,7 @@ import React, {
 import { pagePathUtils, pathUtils } from '@growi/core';
 import { pagePathUtils, pathUtils } from '@growi/core';
 import { format } from 'date-fns';
 import { format } from 'date-fns';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
@@ -21,6 +22,7 @@ const {
 
 
 const PageCreateModal = () => {
 const PageCreateModal = () => {
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const router = useRouter();
 
 
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
 
 
@@ -98,7 +100,10 @@ const PageCreateModal = () => {
   async function redirectToEditor(...paths) {
   async function redirectToEditor(...paths) {
     try {
     try {
       const editorPath = await generateEditorPath(...paths);
       const editorPath = await generateEditorPath(...paths);
-      window.location.href = editorPath;
+      router.push(editorPath);
+
+      // close modal
+      closeCreateModal();
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
@@ -203,7 +208,7 @@ const PageCreateModal = () => {
               {isReachable
               {isReachable
                 ? (
                 ? (
                   <PagePathAutoComplete
                   <PagePathAutoComplete
-                    initializedPath={pageNameInput}
+                    initializedPath={pageNameInputInitialValue}
                     addTrailingSlash
                     addTrailingSlash
                     onSubmit={ppacSubmitHandler}
                     onSubmit={ppacSubmitHandler}
                     onInputChange={value => setPageNameInput(value)}
                     onInputChange={value => setPageNameInput(value)}

+ 6 - 4
packages/app/src/components/PageEditor.tsx

@@ -13,7 +13,8 @@ import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import { getOptionsToSave } from '~/client/util/editor';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import {
 import {
-  useIsEditable, useIsIndentSizeForced, useCurrentPagePath, useCurrentPathname, useCurrentPageId, useIsUploadableFile, useIsUploadableImage,
+  useCurrentPagePath, useCurrentPathname, useCurrentPageId, useEditingMarkdown,
+  useIsEditable, useIsIndentSizeForced, useIsUploadableFile, useIsUploadableImage,
 } from '~/stores/context';
 } from '~/stores/context';
 import {
 import {
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
@@ -51,6 +52,7 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
+  const { data: editingMarkdown } = useEditingMarkdown();
   const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: pageTags } = usePageTagsForEditors(pageId);
   const { data: pageTags } = usePageTagsForEditors(pageId);
 
 
@@ -69,10 +71,10 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: rendererOptions } = usePreviewOptions();
   const { data: rendererOptions } = usePreviewOptions();
 
 
   const currentRevisionId = currentPage?.revision?._id;
   const currentRevisionId = currentPage?.revision?._id;
-  const initialValue = currentPage?.revision?.body;
+  const initialValue = editingMarkdown ?? '';
 
 
-  const markdownToSave = useRef<string>(initialValue ?? '');
-  const [markdownToPreview, setMarkdownToPreview] = useState<string>(initialValue ?? '');
+  const markdownToSave = useRef<string>(initialValue);
+  const [markdownToPreview, setMarkdownToPreview] = useState<string>(initialValue);
 
 
   const slackChannels = useMemo(() => (slackChannelsData ? slackChannelsData.toString() : ''), [slackChannelsData]);
   const slackChannels = useMemo(() => (slackChannelsData ? slackChannelsData.toString() : ''), [slackChannelsData]);
 
 

+ 19 - 16
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useCallback } from 'react';
 
 
 import { createValidator } from '@growi/codemirror-textlint';
 import { createValidator } from '@growi/codemirror-textlint';
 import { commands } from 'codemirror';
 import { commands } from 'codemirror';
@@ -11,13 +11,14 @@ import { throttle, debounce } from 'throttle-debounce';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
 import InterceptorManager from '~/services/interceptor-manager';
 import InterceptorManager from '~/services/interceptor-manager';
+import { useDrawioModal } from '~/stores/modal';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 
 
 import AbstractEditor from './AbstractEditor';
 import AbstractEditor from './AbstractEditor';
 import CommentMentionHelper from './CommentMentionHelper';
 import CommentMentionHelper from './CommentMentionHelper';
-// import DrawioModal from './DrawioModal';
+import { DrawioModal } from './DrawioModal';
 import EditorIcon from './EditorIcon';
 import EditorIcon from './EditorIcon';
 import EmojiPicker from './EmojiPicker';
 import EmojiPicker from './EmojiPicker';
 import EmojiPickerHelper from './EmojiPickerHelper';
 import EmojiPickerHelper from './EmojiPickerHelper';
@@ -154,7 +155,6 @@ class CodeMirrorEditor extends AbstractEditor {
     this.showGridEditorHandler = this.showGridEditorHandler.bind(this);
     this.showGridEditorHandler = this.showGridEditorHandler.bind(this);
     this.showLinkEditHandler = this.showLinkEditHandler.bind(this);
     this.showLinkEditHandler = this.showLinkEditHandler.bind(this);
     this.showHandsonTableHandler = this.showHandsonTableHandler.bind(this);
     this.showHandsonTableHandler = this.showHandsonTableHandler.bind(this);
-    this.showDrawioHandler = this.showDrawioHandler.bind(this);
 
 
     this.foldDrawioSection = this.foldDrawioSection.bind(this);
     this.foldDrawioSection = this.foldDrawioSection.bind(this);
     this.onSaveForDrawio = this.onSaveForDrawio.bind(this);
     this.onSaveForDrawio = this.onSaveForDrawio.bind(this);
@@ -870,10 +870,6 @@ class CodeMirrorEditor extends AbstractEditor {
     // this.handsontableModal.current.show(mtu.getMarkdownTable(this.getCodeMirror()));
     // this.handsontableModal.current.show(mtu.getMarkdownTable(this.getCodeMirror()));
   }
   }
 
 
-  showDrawioHandler() {
-    // this.drawioModal.current.show(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()));
-  }
-
 
 
   // fold draw.io section (::: drawio ~ :::)
   // fold draw.io section (::: drawio ~ :::)
   foldDrawioSection() {
   foldDrawioSection() {
@@ -1025,7 +1021,7 @@ class CodeMirrorEditor extends AbstractEditor {
         color={null}
         color={null}
         bssize="small"
         bssize="small"
         title="draw.io"
         title="draw.io"
-        onClick={this.showDrawioHandler}
+        onClick={() => this.props.onClickDrawioBtn(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()))}
       >
       >
         <EditorIcon icon="Drawio" />
         <EditorIcon icon="Drawio" />
       </Button>,
       </Button>,
@@ -1131,17 +1127,11 @@ class CodeMirrorEditor extends AbstractEditor {
           ref={this.linkEditModal}
           ref={this.linkEditModal}
           onSave={(linkText) => { return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
           onSave={(linkText) => { return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
         />
         />
-        {/*
-        <HandsontableModal
+        {/* <HandsontableModal
           ref={this.handsontableModal}
           ref={this.handsontableModal}
           onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}
           onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}
           autoFormatMarkdownTable={this.props.editorSettings.autoFormatMarkdownTable}
           autoFormatMarkdownTable={this.props.editorSettings.autoFormatMarkdownTable}
         /> */}
         /> */}
-        {/* <DrawioModal
-          ref={this.drawioModal}
-          onSave={this.onSaveForDrawio}
-        /> */}
-
       </div>
       </div>
     );
     );
   }
   }
@@ -1160,4 +1150,17 @@ CodeMirrorEditor.defaultProps = {
   lineNumbers: true,
   lineNumbers: true,
 };
 };
 
 
-export default CodeMirrorEditor;
+
+const CodeMirrorEditorFc = React.forwardRef((props, ref) => {
+  const { open: openDrawioModal } = useDrawioModal();
+
+  const openDrawioModalHandler = useCallback((drawioMxFile) => {
+    openDrawioModal(drawioMxFile);
+  }, [openDrawioModal]);
+
+  return <CodeMirrorEditor ref={ref} onClickDrawioBtn={openDrawioModalHandler} {...props} />;
+});
+
+CodeMirrorEditorFc.displayName = 'CodeMirrorEditorFc';
+
+export default CodeMirrorEditorFc;

+ 0 - 172
packages/app/src/components/PageEditor/DrawioModal.jsx

@@ -1,172 +0,0 @@
-import React from 'react';
-
-import i18next from 'i18next';
-import PropTypes from 'prop-types';
-import {
-  Modal,
-  ModalBody,
-} from 'reactstrap';
-
-import { getDiagramsNetLangCode } from '~/client/util/locale-utils';
-
-class DrawioModal extends React.PureComponent {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      show: false,
-      drawioMxFile: '',
-    };
-
-    this.headerColor = '#334455';
-    this.fontFamily = "Lato, -apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif";
-
-    this.init = this.init.bind(this);
-    this.cancel = this.cancel.bind(this);
-    this.receiveFromDrawio = this.receiveFromDrawio.bind(this);
-  }
-
-  init(drawioMxFile) {
-    const initDrawioMxFile = drawioMxFile;
-    this.setState(
-      {
-        drawioMxFile: initDrawioMxFile,
-      },
-    );
-  }
-
-  show(drawioMxFile) {
-    this.init(drawioMxFile);
-
-    window.addEventListener('message', this.receiveFromDrawio);
-    this.setState({ show: true });
-  }
-
-  hide() {
-    this.setState({
-      show: false,
-    });
-  }
-
-  cancel() {
-    this.hide();
-  }
-
-  receiveFromDrawio(event) {
-    if (event.data === 'ready') {
-      event.source.postMessage(this.state.drawioMxFile, '*');
-      return;
-    }
-
-    if (event.data === '{"event":"configure"}') {
-      if (event.source == null) {
-        return;
-      }
-
-      // refs:
-      //  * https://desk.draw.io/support/solutions/articles/16000103852-how-to-customise-the-draw-io-interface
-      //  * https://desk.draw.io/support/solutions/articles/16000042544-how-does-embed-mode-work-
-      //  * https://desk.draw.io/support/solutions/articles/16000058316-how-to-configure-draw-io-
-      event.source.postMessage(JSON.stringify({
-        action: 'configure',
-        config: {
-          css: `
-          .geMenubarContainer { background-color: ${this.headerColor} !important; }
-          .geMenubar { background-color: ${this.headerColor} !important; }
-          .geEditor { font-family: ${this.fontFamily} !important; }
-          html td.mxPopupMenuItem {
-            font-family: ${this.fontFamily} !important;
-            font-size: 8pt !important;
-          }
-          `,
-          customFonts: ['Lato', 'Charter'],
-        },
-      }), '*');
-
-      return;
-    }
-
-    if (typeof event.data === 'string' && event.data.match(/mxfile/)) {
-      if (event.data.length > 0) {
-        const parser = new DOMParser();
-        const dom = parser.parseFromString(event.data, 'text/xml');
-        const value = dom.getElementsByTagName('diagram')[0].innerHTML;
-
-        if (this.props.onSave != null) {
-          this.props.onSave(value);
-        }
-      }
-
-      window.removeEventListener('message', this.receiveFromDrawio);
-      this.hide();
-
-      return;
-    }
-
-    if (typeof event.data === 'string' && event.data.length === 0) {
-      window.removeEventListener('message', this.receiveFromDrawio);
-      this.hide();
-
-      return;
-    }
-
-    // NOTHING DONE. (Receive unknown iframe message.)
-  }
-
-  get drawioURL() {
-    const { config } = this.props.appContainer;
-
-    const drawioUri = config.env.DRAWIO_URI || 'https://embed.diagrams.net/';
-    const url = new URL(drawioUri);
-
-    // refs: https://desk.draw.io/support/solutions/articles/16000042546-what-url-parameters-are-supported-
-    url.searchParams.append('spin', 1);
-    url.searchParams.append('embed', 1);
-    url.searchParams.append('lang', getDiagramsNetLangCode(i18next.language));
-    url.searchParams.append('ui', 'atlas');
-    url.searchParams.append('configure', 1);
-
-    return url;
-  }
-
-  render() {
-    return (
-      <Modal
-        isOpen={this.state.show}
-        toggle={this.cancel}
-        backdrop="static"
-        className="drawio-modal grw-body-only-modal-expanded"
-        size="xl"
-        keyboard={false}
-      >
-        <ModalBody className="p-0">
-          {/* Loading spinner */}
-          <div className="w-100 h-100 position-absolute d-flex">
-            <div className="mx-auto my-auto">
-              <i className="fa fa-3x fa-spinner fa-pulse mx-auto text-muted"></i>
-            </div>
-          </div>
-          {/* iframe */}
-          <div className="w-100 h-100 position-absolute d-flex">
-            { this.state.show && (
-              <iframe
-                src={this.drawioURL}
-                className="border-0 flex-grow-1"
-              >
-              </iframe>
-            ) }
-          </div>
-        </ModalBody>
-      </Modal>
-    );
-  }
-
-}
-
-DrawioModal.propTypes = {
-  onSave: PropTypes.func,
-};
-
-
-export default DrawioModal;

+ 77 - 0
packages/app/src/components/PageEditor/DrawioModal.tsx

@@ -0,0 +1,77 @@
+import React, {
+  useMemo,
+} from 'react';
+
+import {
+  Modal,
+  ModalBody,
+} from 'reactstrap';
+
+
+import { getDiagramsNetLangCode } from '~/client/util/locale-utils';
+import { useDrawioUri } from '~/stores/context';
+import { useDrawioModal } from '~/stores/modal';
+import { usePersonalSettings } from '~/stores/personal-settings';
+
+
+type Props = {
+  // onSave: (drawioData) => void,
+};
+
+export const DrawioModal = (props: Props): JSX.Element => {
+  const { data: growiDrawioUri } = useDrawioUri();
+  const { data: personalSettingsInfo } = usePersonalSettings();
+
+
+  const { data: drawioModalData, close: closeDrawioModal } = useDrawioModal();
+  const isOpened = drawioModalData?.isOpened ?? false;
+
+  const cancel = () => {
+    closeDrawioModal();
+  };
+
+  const drawioUrl = useMemo(() => {
+    const drawioUri = growiDrawioUri || 'https://embed.diagrams.net/';
+    const url = new URL(drawioUri);
+
+    // refs: https://desk.draw.io/support/solutions/articles/16000042546-what-url-parameters-are-supported-
+    url.searchParams.append('spin', '1');
+    url.searchParams.append('embed', '1');
+    url.searchParams.append('lang', getDiagramsNetLangCode(personalSettingsInfo?.lang || 'en'));
+    url.searchParams.append('ui', 'atlas');
+    url.searchParams.append('configure', '1');
+
+    return url;
+  }, [growiDrawioUri, personalSettingsInfo?.lang]);
+
+
+  return (
+    <Modal
+      isOpen={isOpened}
+      toggle={cancel}
+      backdrop="static"
+      className="drawio-modal grw-body-only-modal-expanded"
+      size="xl"
+      keyboard={false}
+    >
+      <ModalBody className="p-0">
+        {/* Loading spinner */}
+        <div className="w-100 h-100 position-absolute d-flex">
+          <div className="mx-auto my-auto">
+            <i className="fa fa-3x fa-spinner fa-pulse mx-auto text-muted"></i>
+          </div>
+        </div>
+        {/* iframe */}
+        <div className="w-100 h-100 position-absolute d-flex">
+          { isOpened && (
+            <iframe
+              src={drawioUrl.href}
+              className="border-0 flex-grow-1"
+            >
+            </iframe>
+          ) }
+        </div>
+      </ModalBody>
+    </Modal>
+  );
+};

+ 8 - 2
packages/app/src/components/PageEditor/Editor.tsx

@@ -9,12 +9,14 @@ import {
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
+import { IEditorSettings } from '~/interfaces/editor-settings';
 import { useDefaultIndentSize } from '~/stores/context';
 import { useDefaultIndentSize } from '~/stores/context';
 import { useEditorSettings } from '~/stores/editor';
 import { useEditorSettings } from '~/stores/editor';
 import { useIsMobile } from '~/stores/ui';
 import { useIsMobile } from '~/stores/ui';
 
 
 import { IEditorMethods } from '../../interfaces/editor-methods';
 import { IEditorMethods } from '../../interfaces/editor-methods';
 
 
+import AbstractEditor from './AbstractEditor';
 import Cheatsheet from './Cheatsheet';
 import Cheatsheet from './Cheatsheet';
 import CodeMirrorEditor from './CodeMirrorEditor';
 import CodeMirrorEditor from './CodeMirrorEditor';
 import pasteHelper from './PasteHelper';
 import pasteHelper from './PasteHelper';
@@ -31,7 +33,11 @@ export type EditorPropsType = {
   isTextlintEnabled?: boolean,
   isTextlintEnabled?: boolean,
   onChange?: (newValue: string, isClean?: boolean) => void,
   onChange?: (newValue: string, isClean?: boolean) => void,
   onUpload?: (file) => void,
   onUpload?: (file) => void,
+  editorSettings?: IEditorSettings,
   indentSize?: number,
   indentSize?: number,
+  onDragEnter?: (event: any) => void,
+  onMarkdownHelpButtonClicked?: () => void,
+  onAddAttachmentButtonClicked?: () => void,
   onScroll?: (line: { line: number }) => void,
   onScroll?: (line: { line: number }) => void,
   onScrollCursorIntoView?: (line: number) => void,
   onScrollCursorIntoView?: (line: number) => void,
   onSave?: () => Promise<void>,
   onSave?: () => Promise<void>,
@@ -59,7 +65,8 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
   const { data: isMobile } = useIsMobile();
   const { data: isMobile } = useIsMobile();
 
 
   const dropzoneRef = useRef<DropzoneRef>(null);
   const dropzoneRef = useRef<DropzoneRef>(null);
-  const cmEditorRef = useRef<CodeMirrorEditor>(null);
+  // CodeMirrorEditor ref
+  const cmEditorRef = useRef<AbstractEditor<any>>(null);
   const taEditorRef = useRef<TextAreaEditor>(null);
   const taEditorRef = useRef<TextAreaEditor>(null);
 
 
   const editorSubstance = useCallback(() => {
   const editorSubstance = useCallback(() => {
@@ -285,7 +292,6 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
 
 
                 {/* for PC */}
                 {/* for PC */}
                 { !isMobile && (
                 { !isMobile && (
-                  // eslint-disable-next-line arrow-body-style
                   <CodeMirrorEditor
                   <CodeMirrorEditor
                     ref={cmEditorRef}
                     ref={cmEditorRef}
                     indentSize={indentSize ?? defaultIndentSize}
                     indentSize={indentSize ?? defaultIndentSize}

+ 80 - 48
packages/app/src/components/PageEditorByHackmd.tsx

@@ -15,8 +15,10 @@ import { IResHackmdIntegrated, IResHackmdDiscard } from '~/interfaces/hackmd';
 import {
 import {
   useCurrentPagePath, useCurrentPageId, useHackmdUri, usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced,
   useCurrentPagePath, useCurrentPageId, useHackmdUri, usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced,
 } from '~/stores/context';
 } from '~/stores/context';
-import { useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors } from '~/stores/editor';
-import { useSWRxCurrentPage } from '~/stores/page';
+import {
+  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
+} from '~/stores/editor';
+import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import {
 import {
   EditorMode,
   EditorMode,
   useEditorMode, useSelectedGrant,
   useEditorMode, useSelectedGrant,
@@ -42,12 +44,13 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: pageId } = useCurrentPageId();
   const { data: pageId } = useCurrentPageId();
-  const { data: pageTags, mutate: updatePageTagsForEditors } = usePageTagsForEditors(pageId);
+  const { data: pageTags } = usePageTagsForEditors(pageId);
+  const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { data: grant } = useSelectedGrant();
   const { data: grant } = useSelectedGrant();
   const { data: hackmdUri } = useHackmdUri();
   const { data: hackmdUri } = useHackmdUri();
 
 
   // pageData
   // pageData
-  const { data: pageData, mutate: updatePageData } = useSWRxCurrentPage();
+  const { data: pageData, mutate: mutatePageData } = useSWRxCurrentPage();
   const revision = pageData?.revision;
   const revision = pageData?.revision;
 
 
   const slackChannels = slackChannelsData?.toString();
   const slackChannels = slackChannelsData?.toString();
@@ -63,41 +66,61 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: pageIdOnHackmd, mutate: mutatePageIdOnHackmd } = usePageIdOnHackmd();
   const { data: pageIdOnHackmd, mutate: mutatePageIdOnHackmd } = usePageIdOnHackmd();
   const { data: hasDraftOnHackmd, mutate: mutateHasDraftOnHackmd } = useHasDraftOnHackmd();
   const { data: hasDraftOnHackmd, mutate: mutateHasDraftOnHackmd } = useHasDraftOnHackmd();
   const { data: revisionIdHackmdSynced, mutate: mutateRevisionIdHackmdSynced } = useRevisionIdHackmdSynced();
   const { data: revisionIdHackmdSynced, mutate: mutateRevisionIdHackmdSynced } = useRevisionIdHackmdSynced();
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const [isHackmdDraftUpdatingInRealtime, setIsHackmdDraftUpdatingInRealtime] = useState(false);
   const [isHackmdDraftUpdatingInRealtime, setIsHackmdDraftUpdatingInRealtime] = useState(false);
   const [remoteRevisionId, setRemoteRevisionId] = useState(revision?._id); // initialize
   const [remoteRevisionId, setRemoteRevisionId] = useState(revision?._id); // initialize
 
 
   const hackmdEditorRef = useRef<HackEditorRef>(null);
   const hackmdEditorRef = useRef<HackEditorRef>(null);
 
 
   const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
   const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
-    if (editorMode !== EditorMode.HackMD) {
-      return;
-    }
+    if (editorMode !== EditorMode.HackMD) { return }
 
 
-    if (isSlackEnabled == null || currentPathname == null || slackChannels == null || grant == null || revision == null || hackmdEditorRef.current == null) {
-      return;
-    }
+    try {
+      if (isSlackEnabled == null || currentPathname == null || slackChannels == null || grant == null || revision == null || hackmdEditorRef.current == null) {
+        throw new Error('Some materials to save are invalid');
+      }
 
 
-    let optionsToSave;
+      let optionsToSave;
 
 
-    const currentOptionsToSave = getOptionsToSave(
-      isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
-    );
+      const currentOptionsToSave = getOptionsToSave(
+        isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
+      );
 
 
-    if (opts != null) {
-      optionsToSave = Object.assign(currentOptionsToSave, {
-        ...opts,
-      });
-    }
-    else {
-      optionsToSave = currentOptionsToSave;
-    }
+      if (opts != null) {
+        optionsToSave = Object.assign(currentOptionsToSave, {
+          ...opts,
+        });
+      }
+      else {
+        optionsToSave = currentOptionsToSave;
+      }
 
 
-    const markdown = await hackmdEditorRef.current.getValue();
+      const markdown = await hackmdEditorRef.current.getValue();
 
 
-    await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revision?._id }, markdown);
-    await updatePageData();
-    mutateEditorMode(EditorMode.View);
-  }, [currentPagePath, currentPathname, editorMode, grant, isSlackEnabled, pageId, pageTags, revision, slackChannels, mutateEditorMode, updatePageData]);
+      await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revision?._id }, markdown);
+      await mutatePageData();
+      await mutateTagsInfo();
+      mutateEditorMode(EditorMode.View);
+      mutateIsEnabledUnsavedWarning(false);
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      toastError(error.message);
+    }
+  }, [editorMode,
+      isSlackEnabled,
+      currentPathname,
+      slackChannels,
+      grant,
+      revision,
+      pageTags,
+      pageId,
+      currentPagePath,
+      mutatePageData,
+      mutateEditorMode,
+      mutateTagsInfo,
+      mutateIsEnabledUnsavedWarning,
+  ]);
 
 
   // set handler to save and reload Page
   // set handler to save and reload Page
   useEffect(() => {
   useEffect(() => {
@@ -134,7 +157,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       mutateRevisionIdHackmdSynced(res.revisionIdHackmdSynced);
       mutateRevisionIdHackmdSynced(res.revisionIdHackmdSynced);
     }
     }
     catch (err) {
     catch (err) {
-      toastError(err);
+      toastError(err.message);
 
 
       setHasError(true);
       setHasError(true);
       setErrorMessage('GROWI server failed to connect to HackMD.');
       setErrorMessage('GROWI server failed to connect to HackMD.');
@@ -173,7 +196,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);
-      toastError(err);
+      toastError(err.message);
     }
     }
   }, [setIsHackmdDraftUpdatingInRealtime, mutateHasDraftOnHackmd, mutatePageIdOnHackmd, mutateRevisionIdHackmdSynced, pageId]);
   }, [setIsHackmdDraftUpdatingInRealtime, mutateHasDraftOnHackmd, mutatePageIdOnHackmd, mutateRevisionIdHackmdSynced, pageId]);
 
 
@@ -182,27 +205,26 @@ export const PageEditorByHackmd = (): JSX.Element => {
    * @param {string} markdown
    * @param {string} markdown
    */
    */
   const onSaveWithShortcut = useCallback(async(markdown) => {
   const onSaveWithShortcut = useCallback(async(markdown) => {
-    if (
-      isSlackEnabled == null || grant == null || slackChannels == null || pageId == null || revisionIdHackmdSynced == null || currentPathname == null
-    ) { return }
-    const optionsToSave = getOptionsToSave(
-      isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
-    );
-
     try {
     try {
-      const res = await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revisionIdHackmdSynced }, markdown);
+      const currentPagePathOrPathname = currentPagePath || currentPathname;
+      if (
+        isSlackEnabled == null || grant == null || slackChannels == null || pageId == null
+        || revisionIdHackmdSynced == null || currentPagePathOrPathname == null
+      ) { throw new Error('Some materials to save are invalid') }
+      const optionsToSave = getOptionsToSave(
+        isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
+      );
+      const res = await saveOrUpdate(optionsToSave, { pageId, path: currentPagePathOrPathname, revisionId: revisionIdHackmdSynced }, markdown);
 
 
       // update pageData
       // update pageData
-      updatePageData();
+      mutatePageData(res);
 
 
       // set updated data
       // set updated data
       setRemoteRevisionId(res.revision._id);
       setRemoteRevisionId(res.revision._id);
       mutateRevisionIdHackmdSynced(res.page.revisionHackmdSynced);
       mutateRevisionIdHackmdSynced(res.page.revisionHackmdSynced);
       mutateHasDraftOnHackmd(res.page.hasDraftOnHackmd);
       mutateHasDraftOnHackmd(res.page.hasDraftOnHackmd);
-      updatePageTagsForEditors(res.tags);
-
-      // call reset
-      setIsInitialized(false);
+      mutateTagsInfo();
+      mutateIsEnabledUnsavedWarning(false);
 
 
       logger.debug('success to save');
       logger.debug('success to save');
 
 
@@ -210,12 +232,22 @@ export const PageEditorByHackmd = (): JSX.Element => {
     }
     }
     catch (error) {
     catch (error) {
       logger.error('failed to save', error);
       logger.error('failed to save', error);
-      toastError(error);
+      toastError(error.message);
     }
     }
-  }, [
-    grant, isSlackEnabled, pageTags, slackChannels, updatePageTagsForEditors, pageId, currentPagePath, currentPathname,
-    revisionIdHackmdSynced, updatePageData, mutateHasDraftOnHackmd, mutateRevisionIdHackmdSynced, t,
-  ]);
+  }, [isSlackEnabled,
+      grant,
+      slackChannels,
+      pageId,
+      revisionIdHackmdSynced,
+      currentPathname,
+      pageTags,
+      currentPagePath,
+      mutatePageData,
+      mutateRevisionIdHackmdSynced,
+      mutateHasDraftOnHackmd,
+      mutateTagsInfo,
+      mutateIsEnabledUnsavedWarning,
+      t]);
 
 
   /**
   /**
    * onChange event of HackmdEditor handler
    * onChange event of HackmdEditor handler
@@ -240,7 +272,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
   }, [pageId, revision?.body, hackmdUri]);
   }, [pageId, revision?.body, hackmdUri]);
 
 
   const penpalErrorOccuredHandler = useCallback((error) => {
   const penpalErrorOccuredHandler = useCallback((error) => {
-    toastError(error);
+    toastError(error.message);
 
 
     setHasError(true);
     setHasError(true);
     setErrorMessage(t('hackmd.fail_to_connect'));
     setErrorMessage(t('hackmd.fail_to_connect'));

+ 8 - 7
packages/app/src/components/PasswordResetExecutionForm.jsx → packages/app/src/components/PasswordResetExecutionForm.tsx

@@ -1,6 +1,7 @@
-import React, { useState } from 'react';
+import React, { FC, useState } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 
 
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
@@ -9,7 +10,7 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:passwordReset');
 const logger = loggerFactory('growi:passwordReset');
 
 
 
 
-const PasswordResetExecutionForm = (props) => {
+const PasswordResetExecutionForm: FC = () => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const [newPassword, setNewPassword] = useState('');
   const [newPassword, setNewPassword] = useState('');
@@ -79,14 +80,14 @@ const PasswordResetExecutionForm = (props) => {
       <div className="form-group">
       <div className="form-group">
         <input name="reset-password-btn" className="btn btn-lg btn-primary btn-block" value={t('forgot_password.reset_password')} type="submit" />
         <input name="reset-password-btn" className="btn btn-lg btn-primary btn-block" value={t('forgot_password.reset_password')} type="submit" />
       </div>
       </div>
-      <a href="/login">
-        <i className="icon-login mr-1"></i>{t('forgot_password.sign_in_instead')}
-      </a>
+      <Link href="/login" prefetch={false}>
+        <a>
+          <i className="icon-login mr-1"></i>{t('forgot_password.sign_in_instead')}
+        </a>
+      </Link>
     </form>
     </form>
   );
   );
 };
 };
 
 
-PasswordResetExecutionForm.propTypes = {
-};
 
 
 export default PasswordResetExecutionForm;
 export default PasswordResetExecutionForm;

+ 13 - 12
packages/app/src/components/PasswordResetRequestForm.jsx → packages/app/src/components/PasswordResetRequestForm.tsx

@@ -1,20 +1,21 @@
-import React, { useState } from 'react';
+import React, { FC, useState, useCallback } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 
 
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
 
 
 
 
-const PasswordResetRequestForm = (props) => {
+const PasswordResetRequestForm: FC = () => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [email, setEmail] = useState('');
   const [email, setEmail] = useState('');
 
 
-  const changeEmail = (inputValue) => {
+  const changeEmail = useCallback((inputValue) => {
     setEmail(inputValue);
     setEmail(inputValue);
-  };
+  }, []);
 
 
-  const sendPasswordResetRequestMail = async(e) => {
+  const sendPasswordResetRequestMail = useCallback(async(e) => {
     e.preventDefault();
     e.preventDefault();
     if (email === '') {
     if (email === '') {
       toastError('err', t('forgot_password.email_is_required'));
       toastError('err', t('forgot_password.email_is_required'));
@@ -28,10 +29,11 @@ const PasswordResetRequestForm = (props) => {
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-  };
+  }, [t, email]);
 
 
   return (
   return (
     <form onSubmit={sendPasswordResetRequestMail}>
     <form onSubmit={sendPasswordResetRequestMail}>
+      <h3>{ t('forgot_password.password_reset_request_desc') }</h3>
       <div className="form-group">
       <div className="form-group">
         <div className="input-group">
         <div className="input-group">
           <input name="email" placeholder="E-mail Address" className="form-control" type="email" onChange={e => changeEmail(e.target.value)} />
           <input name="email" placeholder="E-mail Address" className="form-control" type="email" onChange={e => changeEmail(e.target.value)} />
@@ -45,14 +47,13 @@ const PasswordResetRequestForm = (props) => {
           {t('forgot_password.send')}
           {t('forgot_password.send')}
         </button>
         </button>
       </div>
       </div>
-      <a href="/login">
-        <i className="icon-login mr-1"></i>{t('forgot_password.return_to_login')}
-      </a>
+      <Link href='/login' prefetch={false}>
+        <a>
+          <i className="icon-login mr-1" />{t('forgot_password.return_to_login')}
+        </a>
+      </Link>
     </form>
     </form>
   );
   );
 };
 };
 
 
-PasswordResetRequestForm.propTypes = {
-};
-
 export default PasswordResetRequestForm;
 export default PasswordResetRequestForm;

+ 2 - 8
packages/app/src/components/SavePageControls.tsx

@@ -36,7 +36,6 @@ export const SavePageControls = (props: Props): JSX.Element | null => {
   const { data: isAclEnabled } = useIsAclEnabled();
   const { data: isAclEnabled } = useIsAclEnabled();
   const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: pageId } = useCurrentPageId();
   const { data: pageId } = useCurrentPageId();
-  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
 
 
 
 
   const updateGrantHandler = useCallback((grantData: IPageGrantData): void => {
   const updateGrantHandler = useCallback((grantData: IPageGrantData): void => {
@@ -44,19 +43,14 @@ export const SavePageControls = (props: Props): JSX.Element | null => {
   }, [mutateGrant]);
   }, [mutateGrant]);
 
 
   const save = useCallback(async(): Promise<void> => {
   const save = useCallback(async(): Promise<void> => {
-    // disable unsaved warning
-    mutateIsEnabledUnsavedWarning(false);
-
     // save
     // save
     (window as CustomWindow).globalEmitter.emit('saveAndReturnToView');
     (window as CustomWindow).globalEmitter.emit('saveAndReturnToView');
-  }, [mutateIsEnabledUnsavedWarning]);
+  }, []);
 
 
   const saveAndOverwriteScopesOfDescendants = useCallback(() => {
   const saveAndOverwriteScopesOfDescendants = useCallback(() => {
-    // disable unsaved warning
-    mutateIsEnabledUnsavedWarning(false);
     // save
     // save
     (window as CustomWindow).globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true });
     (window as CustomWindow).globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true });
-  }, [mutateIsEnabledUnsavedWarning]);
+  }, []);
 
 
 
 
   if (isEditable == null || isAclEnabled == null || grantData == null) {
   if (isEditable == null || isAclEnabled == null || grantData == null) {

+ 4 - 3
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -170,7 +170,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
   }, [onDeletedHandler, openDeleteModal]);
   }, [onDeletedHandler, openDeleteModal]);
 
 
-  const ControlComponents = useCallback(() => {
+  const RightComponent = useCallback(() => {
     if (page == null) {
     if (page == null) {
       return <></>;
       return <></>;
     }
     }
@@ -202,8 +202,9 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     <div key={page._id} data-testid="search-result-content" className="search-result-content grw-page-path-text-muted-container d-flex flex-column">
     <div key={page._id} data-testid="search-result-content" className="search-result-content grw-page-path-text-muted-container d-flex flex-column">
       <div className="grw-subnav-append-shadow-container">
       <div className="grw-subnav-append-shadow-container">
         <GrowiSubNavigation
         <GrowiSubNavigation
-          page={page}
-          controls={ControlComponents}
+          pagePath={page.path}
+          pageId={page._id}
+          rightComponent={RightComponent}
           isCompactMode
           isCompactMode
           additionalClasses={['px-4']}
           additionalClasses={['px-4']}
         />
         />

+ 2 - 2
packages/app/src/components/Skelton.tsx

@@ -11,8 +11,8 @@ export const Skelton = (props: SkeltonProps): JSX.Element => {
   } = props;
   } = props;
 
 
   return (
   return (
-    <div className={`${additionalClass}`}>
-      <div className={`grw-skelton h-100 w-100 ${roundedPill ? 'rounded-pill' : ''}`}></div>
+    <div className={`${additionalClass ?? ''}`}>
+      <div className={`grw-skelton h-100 w-100 ${roundedPill ?? ''}`}></div>
     </div>
     </div>
   );
   );
 };
 };

+ 16 - 12
packages/app/src/components/User/UserInfo.tsx

@@ -1,37 +1,41 @@
 import React from 'react';
 import React from 'react';
 
 
+import { IUserHasId } from '@growi/core';
 import { UserPicture } from '@growi/ui';
 import { UserPicture } from '@growi/ui';
 
 
-import { usePageUser } from '~/stores/context';
-
 import styles from './UserInfo.module.scss';
 import styles from './UserInfo.module.scss';
 
 
-export const UserInfo = (): JSX.Element => {
 
 
-  const { data: pageUser } = usePageUser();
+export type UserInfoProps = {
+  author?: IUserHasId,
+}
+
+export const UserInfo = (props: UserInfoProps): JSX.Element => {
+
+  const { author } = props;
 
 
-  if (pageUser == null || pageUser.status === 4) {
+  if (author == null || author.status === 4) {
     return <></>;
     return <></>;
   }
   }
 
 
   return (
   return (
     <div className={`${styles['grw-users-info']} grw-users-info d-flex align-items-center d-edit-none mb-5 pb-3 border-bottom`}>
     <div className={`${styles['grw-users-info']} grw-users-info d-flex align-items-center d-edit-none mb-5 pb-3 border-bottom`}>
-      <UserPicture user={pageUser} />
+      <UserPicture user={author} />
       <div className="users-meta">
       <div className="users-meta">
         <h1 className="user-page-name">
         <h1 className="user-page-name">
-          {pageUser.name}
+          {author.name}
         </h1>
         </h1>
         <div className="user-page-meta mt-3 mb-0">
         <div className="user-page-meta mt-3 mb-0">
-          <span className="user-page-username mr-4"><i className="icon-user mr-1"></i>{pageUser.username}</span>
+          <span className="user-page-username mr-4"><i className="icon-user mr-1"></i>{author.username}</span>
           <span className="user-page-email mr-2">
           <span className="user-page-email mr-2">
             <i className="icon-envelope mr-1"></i>
             <i className="icon-envelope mr-1"></i>
-            { pageUser.isEmailPublished
-              ? pageUser.email
+            { author.isEmailPublished
+              ? author.email
               : '*****'
               : '*****'
             }
             }
           </span>
           </span>
-          { pageUser.introduction && (
-            <span className="user-page-introduction">{pageUser.introduction}</span>
+          { author.introduction && (
+            <span className="user-page-introduction">{author.introduction}</span>
           ) }
           ) }
         </div>
         </div>
       </div>
       </div>

+ 7 - 0
packages/app/src/interfaces/errors/forgot-password.ts

@@ -0,0 +1,7 @@
+export const forgotPasswordErrorCode = {
+  PASSWORD_RESET_IS_UNAVAILABLE: 'password-reset-is-unavailable',
+  TOKEN_NOT_FOUND: 'token-not-found',
+  PASSWORD_RESET_ORDER_IS_NOT_APPROPRIATE: 'password-reset-order-is-not-appropriate',
+} as const;
+
+export type forgotPasswordErrorCode = typeof forgotPasswordErrorCode[keyof typeof forgotPasswordErrorCode]

+ 0 - 7
packages/app/src/interfaces/services/renderer.ts

@@ -1,12 +1,5 @@
 import { XssOptionConfig } from '~/services/xss/xssOption';
 import { XssOptionConfig } from '~/services/xss/xssOption';
 
 
-// export type GrowiHydratedEnv = {
-//   DRAWIO_URI: string | null,
-//   HACKMD_URI: string | null,
-//   NO_CDN: string | null,
-//   GROWI_CLOUD_URI: string | null,
-//   GROWI_APP_ID_FOR_GROWI_CLOUD: string | null,
-// }
 
 
 export type RendererConfig = {
 export type RendererConfig = {
   isEnabledLinebreaks: boolean,
   isEnabledLinebreaks: boolean,

+ 25 - 23
packages/app/src/pages/[[...path]].page.tsx

@@ -58,11 +58,11 @@ import {
   useIsForbidden, useIsNotFound, useIsTrashPage, useIsSharedUser,
   useIsForbidden, useIsNotFound, useIsTrashPage, useIsSharedUser,
   useIsEnabledStaleNotification, useIsIdenticalPath,
   useIsEnabledStaleNotification, useIsIdenticalPath,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useDisableLinkSharing,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useDisableLinkSharing,
-  useHackmdUri,
+  useDrawioUri, useHackmdUri,
   useIsAclEnabled, useIsUserPage,
   useIsAclEnabled, useIsUserPage,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
   useIsSlackConfigured, useRendererConfig, useEditingMarkdown,
   useIsSlackConfigured, useRendererConfig, useEditingMarkdown,
-  useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, usePageUser,
+  useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage,
 } from '../stores/context';
 } from '../stores/context';
 
 
 import {
 import {
@@ -126,7 +126,7 @@ const PutbackPageModal = (): JSX.Element => {
 type Props = CommonProps & {
 type Props = CommonProps & {
   currentUser: IUser,
   currentUser: IUser,
 
 
-  pageWithMeta: IPageToShowRevisionWithMeta,
+  pageWithMeta: IPageToShowRevisionWithMeta | null,
   // pageUser?: any,
   // pageUser?: any,
   redirectFrom?: string;
   redirectFrom?: string;
 
 
@@ -147,9 +147,9 @@ type Props = CommonProps & {
   // isMailerSetup: boolean,
   // isMailerSetup: boolean,
   isAclEnabled: boolean,
   isAclEnabled: boolean,
   // hasSlackConfig: boolean,
   // hasSlackConfig: boolean,
-  // drawioUri: string,
+  drawioUri: string,
   hackmdUri: string,
   hackmdUri: string,
-  // noCdn: string,
+  noCdn: string,
   // highlightJsStyle: string,
   // highlightJsStyle: string,
   isAllReplyShown: boolean,
   isAllReplyShown: boolean,
   // isContainerFluid: boolean,
   // isContainerFluid: boolean,
@@ -213,12 +213,11 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   // useIsMailerSetup(props.isMailerSetup);
   // useIsMailerSetup(props.isMailerSetup);
   useIsAclEnabled(props.isAclEnabled);
   useIsAclEnabled(props.isAclEnabled);
   // useHasSlackConfig(props.hasSlackConfig);
   // useHasSlackConfig(props.hasSlackConfig);
-  // useDrawioUri(props.drawioUri);
+  useDrawioUri(props.drawioUri);
   useHackmdUri(props.hackmdUri);
   useHackmdUri(props.hackmdUri);
   // useNoCdn(props.noCdn);
   // useNoCdn(props.noCdn);
   // useIndentSize(props.adminPreferredIndentSize);
   // useIndentSize(props.adminPreferredIndentSize);
   useDisableLinkSharing(props.disableLinkSharing);
   useDisableLinkSharing(props.disableLinkSharing);
-
   useRendererConfig(props.rendererConfig);
   useRendererConfig(props.rendererConfig);
   // useRendererSettings(props.rendererSettingsStr != null ? JSON.parse(props.rendererSettingsStr) : undefined);
   // useRendererSettings(props.rendererSettingsStr != null ? JSON.parse(props.rendererSettingsStr) : undefined);
   // useGrowiRendererConfig(props.growiRendererConfigStr != null ? JSON.parse(props.growiRendererConfigStr) : undefined);
   // useGrowiRendererConfig(props.growiRendererConfigStr != null ? JSON.parse(props.growiRendererConfigStr) : undefined);
@@ -231,27 +230,25 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
 
 
   const { pageWithMeta, userUISettings } = props;
   const { pageWithMeta, userUISettings } = props;
 
 
-  let shouldRenderPutbackPageModal = false;
-  if (pageWithMeta != null) {
-    shouldRenderPutbackPageModal = _isTrashPage(pageWithMeta.data.path);
-  }
+  useSWRxCurrentPage(undefined, pageWithMeta?.data ?? null); // store initial data
+  useEditingMarkdown(pageWithMeta?.data.revision?.body ?? '');
 
 
   const pageId = pageWithMeta?.data._id;
   const pageId = pageWithMeta?.data._id;
-  const pagePath = pageWithMeta?.data.path ?? props.currentPathname;
+  const pagePath = pageWithMeta?.data.path ?? (!_isPermalink(props.currentPathname) ? props.currentPathname : undefined);
 
 
-  useCurrentPageId(pageId);
-  useSWRxCurrentPage(undefined, pageWithMeta?.data); // store initial data
-  useIsUserPage(isUserPage(pagePath));
+  useCurrentPageId(pageId ?? null);
+  useIsUserPage(pagePath != null && isUserPage(pagePath));
   // useIsNotCreatable(props.isForbidden || !isCreatablePage(pagePath)); // TODO: need to include props.isIdentical
   // useIsNotCreatable(props.isForbidden || !isCreatablePage(pagePath)); // TODO: need to include props.isIdentical
   useCurrentPagePath(pagePath);
   useCurrentPagePath(pagePath);
   useCurrentPathname(props.currentPathname);
   useCurrentPathname(props.currentPathname);
-  useEditingMarkdown(pageWithMeta?.data.revision?.body);
-  useIsTrashPage(_isTrashPage(pagePath));
+  useIsTrashPage(pagePath != null && _isTrashPage(pagePath));
 
 
   const { data: grantData } = useSWRxIsGrantNormalized(pageId);
   const { data: grantData } = useSWRxIsGrantNormalized(pageId);
   const { mutate: mutateSelectedGrant } = useSelectedGrant();
   const { mutate: mutateSelectedGrant } = useSelectedGrant();
 
 
-  usePageUser(pageWithMeta?.data.creator);
+  const shouldRenderPutbackPageModal = pageWithMeta != null
+    ? _isTrashPage(pageWithMeta.data.path)
+    : false;
 
 
   // sync grant data
   // sync grant data
   useEffect(() => {
   useEffect(() => {
@@ -329,8 +326,8 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
           </div>
           </div>
           { !props.isIdenticalPathPage && !props.isNotFound && (
           { !props.isIdenticalPathPage && !props.isNotFound && (
             <footer className="footer d-edit-none">
             <footer className="footer d-edit-none">
-              { !isTopPagePath && (<Comments pageId={pageId} revision={pageWithMeta?.data.revision} />) }
-              { (pageWithMeta != null && isUsersHomePage(pageWithMeta.data.path)) && (
+              { pageWithMeta != null && !isTopPagePath && (<Comments pageId={pageId} revision={pageWithMeta.data.revision} />) }
+              { pageWithMeta != null && isUsersHomePage(pageWithMeta.data.path) && (
                 <UsersHomePageFooter creatorId={pageWithMeta.data.creator._id}/>
                 <UsersHomePageFooter creatorId={pageWithMeta.data.creator._id}/>
               ) }
               ) }
               <CurrentPageContentFooter />
               <CurrentPageContentFooter />
@@ -396,9 +393,14 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
     }
     }
   }
   }
 
 
-  const pageWithMeta: IPageToShowRevisionWithMeta = await pageService.findPageAndMetaDataByViewer(pageId, currentPathname, user, true); // includeEmpty = true, isSharedPage = false
+  const pageWithMeta: IPageToShowRevisionWithMeta | null = await pageService.findPageAndMetaDataByViewer(pageId, currentPathname, user, true); // includeEmpty = true, isSharedPage = false
   const page = pageWithMeta?.data as unknown as PageDocument;
   const page = pageWithMeta?.data as unknown as PageDocument;
 
 
+  // add user to seen users
+  if (page != null && user != null) {
+    await page.seen(user);
+  }
+
   // populate & check if the revision is latest
   // populate & check if the revision is latest
   if (page != null) {
   if (page != null) {
     page.initLatestRevisionField(revisionId);
     page.initLatestRevisionField(revisionId);
@@ -488,9 +490,9 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   // props.isMailerSetup = mailService.isMailerSetup;
   // props.isMailerSetup = mailService.isMailerSetup;
   props.isAclEnabled = aclService.isAclEnabled();
   props.isAclEnabled = aclService.isAclEnabled();
   // props.hasSlackConfig = slackNotificationService.hasSlackConfig();
   // props.hasSlackConfig = slackNotificationService.hasSlackConfig();
-  // props.drawioUri = configManager.getConfig('crowi', 'app:drawioUri');
+  props.drawioUri = configManager.getConfig('crowi', 'app:drawioUri');
   props.hackmdUri = configManager.getConfig('crowi', 'app:hackmdUri');
   props.hackmdUri = configManager.getConfig('crowi', 'app:hackmdUri');
-  // props.noCdn = configManager.getConfig('crowi', 'app:noCdn');
+  props.noCdn = configManager.getConfig('crowi', 'app:noCdn');
   // props.highlightJsStyle = configManager.getConfig('crowi', 'customize:highlightJsStyle');
   // props.highlightJsStyle = configManager.getConfig('crowi', 'customize:highlightJsStyle');
   props.isAllReplyShown = configManager.getConfig('crowi', 'customize:isAllReplyShown');
   props.isAllReplyShown = configManager.getConfig('crowi', 'customize:isAllReplyShown');
   // props.isContainerFluid = configManager.getConfig('crowi', 'customize:isContainerFluid');
   // props.isContainerFluid = configManager.getConfig('crowi', 'customize:isContainerFluid');

+ 90 - 0
packages/app/src/pages/forgot-password-errors.page.tsx

@@ -0,0 +1,90 @@
+import React from 'react';
+
+import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
+import { useTranslation } from 'next-i18next';
+import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
+import Link from 'next/link';
+
+import { forgotPasswordErrorCode } from '~/interfaces/errors/forgot-password';
+
+import {
+  CommonProps, getNextI18NextConfig, getServerSideCommonProps,
+} from './utils/commons';
+
+type Props = CommonProps & {
+  errorCode?: forgotPasswordErrorCode
+};
+
+const ForgotPasswordErrorsPage: NextPage<Props> = (props: Props) => {
+  const { t } = useTranslation();
+  const { errorCode } = props;
+
+  return (
+    <div id="main" className="main">
+      <div id="content-main" className="content-main container-lg">
+        <div className="container">
+          <div className="row justify-content-md-center">
+            <div className="col-md-6 mt-5">
+              <div className="text-center">
+                <h1><i className="icon-lock-open large"/></h1>
+                <h2 className="text-center">{ t('forgot_password.reset_password') }</h2>
+
+                { errorCode == null && (
+                  <h3 className="text-muted">errorCode Unknown</h3>
+                )}
+
+                { errorCode === forgotPasswordErrorCode.PASSWORD_RESET_IS_UNAVAILABLE && (
+                  <h3 className="text-muted">{ t('forgot_password.feature_is_unavailable') }</h3>
+                )}
+
+                { (errorCode === forgotPasswordErrorCode.PASSWORD_RESET_ORDER_IS_NOT_APPROPRIATE || errorCode === forgotPasswordErrorCode.TOKEN_NOT_FOUND) && (
+                  <div>
+                    <div className="alert alert-warning mb-3">
+                      <h2>{ t('forgot_password.incorrect_token_or_expired_url') }</h2>
+                    </div>
+                    <Link href="/forgot-password" prefetch={false}>
+                      <a className="link-switch">
+                        <i className="icon-key"></i> { t('forgot_password.forgot_password') }
+                      </a>
+                    </Link>
+                  </div>
+                ) }
+
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
+  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
+  props._nextI18Next = nextI18NextConfig._nextI18Next;
+}
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const result = await getServerSideCommonProps(context);
+
+  // check for presence
+  // see: https://github.com/vercel/next.js/issues/19271#issuecomment-730006862
+  if (!('props' in result)) {
+    throw new Error('invalid getSSP result');
+  }
+
+  const props: Props = result.props as Props;
+
+  const errorCode = context.query.errorCode;
+  if (typeof errorCode === 'string') {
+    props.errorCode = errorCode as forgotPasswordErrorCode;
+  }
+
+  await injectNextI18NextConfigurations(context, props, ['translation']);
+
+  return {
+    props,
+  };
+};
+
+export default ForgotPasswordErrorsPage;

+ 60 - 0
packages/app/src/pages/forgot-password.page.tsx

@@ -0,0 +1,60 @@
+import React from 'react';
+
+import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
+import { useTranslation } from 'next-i18next';
+import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
+import dynamic from 'next/dynamic';
+
+import {
+  CommonProps, getNextI18NextConfig, getServerSideCommonProps,
+} from './utils/commons';
+
+const PasswordResetRequestForm = dynamic(() => import('~/components/PasswordResetRequestForm'), { ssr: false });
+
+const ForgotPasswordPage: NextPage = () => {
+  const { t } = useTranslation();
+
+  return (
+    <div id="main" className="main">
+      <div id="content-main" className="content-main container-lg">
+        <div className="container">
+          <div className="row justify-content-md-center">
+            <div className="col-md-6 mt-5">
+              <div className="text-center">
+                <h1><i className="icon-lock large"></i></h1>
+                <h1 className="text-center">{ t('forgot_password.forgot_password') }</h1>
+                <PasswordResetRequestForm />
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+// eslint-disable-next-line max-len
+async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: CommonProps, namespacesRequired?: string[] | undefined): Promise<void> {
+  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
+  props._nextI18Next = nextI18NextConfig._nextI18Next;
+}
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const result = await getServerSideCommonProps(context);
+
+  // check for presence
+  // see: https://github.com/vercel/next.js/issues/19271#issuecomment-730006862
+  if (!('props' in result)) {
+    throw new Error('invalid getSSP result');
+  }
+
+  const props: CommonProps = result.props as CommonProps;
+
+  await injectNextI18NextConfigurations(context, props, ['translation']);
+
+  return {
+    props,
+  };
+};
+
+export default ForgotPasswordPage;

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

@@ -196,7 +196,7 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
 
 
   await injectUserUISettings(context, props);
   await injectUserUISettings(context, props);
   await injectServerConfigurations(context, props);
   await injectServerConfigurations(context, props);
-  await injectNextI18NextConfigurations(context, props, ['translation']);
+  await injectNextI18NextConfigurations(context, props, ['translation', 'admin']);
 
 
   return {
   return {
     props,
     props,

+ 72 - 0
packages/app/src/pages/reset-password.page.tsx

@@ -0,0 +1,72 @@
+import React from 'react';
+
+import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
+import { useTranslation } from 'next-i18next';
+import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
+import dynamic from 'next/dynamic';
+
+import {
+  CommonProps, getNextI18NextConfig, getServerSideCommonProps,
+} from './utils/commons';
+
+
+type Props = CommonProps & {
+  email: string
+};
+
+const PasswordResetExecutionForm = dynamic(() => import('~/components/PasswordResetExecutionForm'), { ssr: false });
+
+const ForgotPasswordPage: NextPage<Props> = (props: Props) => {
+  const { t } = useTranslation();
+
+  return (
+    <div id="main" className="main">
+      <div id="content-main" className="content-main container-lg">
+        <div className="container">
+          <div className="row justify-content-md-center">
+            <div className="col-md-6 mt-5">
+              <div className="text-center">
+                <h1><i className="icon-lock-open large"></i></h1>
+                <h2 className="text-center">{ t('forgot_password.reset_password') }</h2>
+                <h5>{ props.email }</h5>
+                <p className="mt-4">{ t('forgot_password.password_reset_excecution_desc') }</p>
+                <PasswordResetExecutionForm />
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+// eslint-disable-next-line max-len
+async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
+  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
+  props._nextI18Next = nextI18NextConfig._nextI18Next;
+}
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const result = await getServerSideCommonProps(context);
+
+  // check for presence
+  // see: https://github.com/vercel/next.js/issues/19271#issuecomment-730006862
+  if (!('props' in result)) {
+    throw new Error('invalid getSSP result');
+  }
+
+  const props: Props = result.props as Props;
+
+  const email = context.query.email;
+  if (typeof email === 'string') {
+    props.email = email;
+  }
+
+  await injectNextI18NextConfigurations(context, props, ['translation']);
+
+  return {
+    props,
+  };
+};
+
+export default ForgotPasswordPage;

+ 15 - 2
packages/app/src/server/middlewares/inject-reset-order-by-token-middleware.ts

@@ -1,24 +1,37 @@
 import { NextFunction, Request, Response } from 'express';
 import { NextFunction, Request, Response } from 'express';
 import createError from 'http-errors';
 import createError from 'http-errors';
 
 
+import { forgotPasswordErrorCode } from '~/interfaces/errors/forgot-password';
+import loggerFactory from '~/utils/logger';
+
 import PasswordResetOrder, { IPasswordResetOrder } from '../models/password-reset-order';
 import PasswordResetOrder, { IPasswordResetOrder } from '../models/password-reset-order';
 
 
+const logger = loggerFactory('growi:routes:forgot-password');
+
 export type ReqWithPasswordResetOrder = Request & {
 export type ReqWithPasswordResetOrder = Request & {
   passwordResetOrder: IPasswordResetOrder,
   passwordResetOrder: IPasswordResetOrder,
 };
 };
 
 
+// eslint-disable-next-line import/no-anonymous-default-export
 export default async(req: ReqWithPasswordResetOrder, res: Response, next: NextFunction): Promise<void> => {
 export default async(req: ReqWithPasswordResetOrder, res: Response, next: NextFunction): Promise<void> => {
   const token = req.params.token || req.body.token;
   const token = req.params.token || req.body.token;
 
 
   if (token == null) {
   if (token == null) {
-    return next(createError(400, 'Token not found', { code: 'token-not-found' }));
+    logger.error('Token not found');
+    return next(createError(400, 'Token not found', { code: forgotPasswordErrorCode.TOKEN_NOT_FOUND }));
   }
   }
 
 
   const passwordResetOrder = await PasswordResetOrder.findOne({ token });
   const passwordResetOrder = await PasswordResetOrder.findOne({ token });
 
 
   // check if the token is valid
   // check if the token is valid
   if (passwordResetOrder == null || passwordResetOrder.isExpired() || passwordResetOrder.isRevoked) {
   if (passwordResetOrder == null || passwordResetOrder.isExpired() || passwordResetOrder.isRevoked) {
-    return next(createError(400, 'passwordResetOrder is null or expired or revoked', { code: 'password-reset-order-is-not-appropriate' }));
+    const message = 'passwordResetOrder is null or expired or revoked';
+    logger.error(message);
+    return next(createError(
+      400,
+      'passwordResetOrder is null or expired or revoked',
+      { code: forgotPasswordErrorCode.PASSWORD_RESET_ORDER_IS_NOT_APPROPRIATE },
+    ));
   }
   }
 
 
   req.passwordResetOrder = passwordResetOrder;
   req.passwordResetOrder = passwordResetOrder;

+ 45 - 0
packages/app/src/server/middlewares/invited-form-validator.ts

@@ -0,0 +1,45 @@
+import { NextFunction, Response } from 'express';
+import { body, validationResult, ValidationChain } from 'express-validator';
+import { Request } from 'express-validator/src/base';
+
+const MININUM_PASSWORD_LENGTH = 6;
+
+export const invitedRules = (): ValidationChain[] => {
+  return [
+    body('invitedForm.username')
+      .matches(/^[\da-zA-Z\-_.]+$/)
+      .withMessage('message.Username has invalid characters')
+      .not()
+      .isEmpty()
+      .withMessage('message.Username field is required'),
+    body('invitedForm.name')
+      .not()
+      .isEmpty()
+      .withMessage('message.Name field is required'),
+    body('invitedForm.password')
+      .matches(/^[\x20-\x7F]*$/)
+      .withMessage('message.Password has invalid character')
+      .isLength({ min: MININUM_PASSWORD_LENGTH })
+      .withMessage(`message.Password minimum character should be more than ${MININUM_PASSWORD_LENGTH} characters`)
+      .not()
+      .isEmpty()
+      .withMessage('message.Password field is required'),
+  ];
+};
+
+export const invitedValidation = (req: Request, _res: Response, next: () => NextFunction): any => {
+  const form = req.body;
+  const errors = validationResult(req);
+  const extractedErrors: string[] = [];
+
+  if (errors.isEmpty()) {
+    Object.assign(form, { isValid: true });
+  }
+  else {
+    errors.array().map(err => extractedErrors.push(err.msg));
+    Object.assign(form, { isValid: false, errors: extractedErrors });
+  }
+
+  req.form = form;
+  return next();
+};

+ 0 - 43
packages/app/src/server/middlewares/login-form-validator.ts

@@ -1,48 +1,5 @@
 import { body, validationResult } from 'express-validator';
 import { body, validationResult } from 'express-validator';
 
 
-// form rules
-export const inviteRules = () => {
-  return [
-    body('invitedForm.username')
-      .matches(/^[\da-zA-Z\-_.]+$/)
-      .withMessage('Username has invalid characters')
-      .not()
-      .isEmpty()
-      .withMessage('Username field is required'),
-    body('invitedForm.name').not().isEmpty().withMessage('Name field is required'),
-    body('invitedForm.password')
-      .matches(/^[\x20-\x7F]*$/)
-      .withMessage('Password has invalid character')
-      .isLength({ min: 6 })
-      .withMessage('Password minimum character should be more than 6 characters')
-      .not()
-      .isEmpty()
-      .withMessage('Password field is required'),
-  ];
-};
-
-// validation action
-export const inviteValidation = (req, res, next) => {
-  const form = req.body;
-
-  const errors = validationResult(req);
-  if (errors.isEmpty()) {
-    Object.assign(form, { isValid: true });
-    req.form = form;
-    return next();
-  }
-
-  const extractedErrors: string[] = [];
-  errors.array().map(err => extractedErrors.push(err.msg));
-
-  req.flash('errorMessages', extractedErrors);
-
-  Object.assign(form, { isValid: false });
-  req.form = form;
-
-  return next();
-};
-
 // form rules
 // form rules
 export const loginRules = () => {
 export const loginRules = () => {
   return [
   return [

+ 2 - 0
packages/app/src/server/routes/apiv3/index.js

@@ -49,11 +49,13 @@ module.exports = (crowi, app, isInstalled) => {
   routerForAuth.post('/login', applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation,
   routerForAuth.post('/login', applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation,
     addActivity, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.cannotLoginErrorHadnler, loginPassport.loginFailure);
     addActivity, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.cannotLoginErrorHadnler, loginPassport.loginFailure);
 
 
+  routerForAuth.use('/invited', require('./invited')(crowi));
   routerForAuth.use('/logout', require('./logout')(crowi));
   routerForAuth.use('/logout', require('./logout')(crowi));
 
 
   routerForAuth.post('/register',
   routerForAuth.post('/register',
     applicationInstalled, registerFormValidator.registerRules(), registerFormValidator.registerValidation, addActivity, login.register);
     applicationInstalled, registerFormValidator.registerRules(), registerFormValidator.registerValidation, addActivity, login.register);
 
 
+
   // installer
   // installer
   if (!isInstalled) {
   if (!isInstalled) {
     routerForAdmin.use('/installer', require('./installer')(crowi));
     routerForAdmin.use('/installer', require('./installer')(crowi));

+ 53 - 0
packages/app/src/server/routes/apiv3/invited.ts

@@ -0,0 +1,53 @@
+import express, { Request, Router } from 'express';
+
+import Crowi from '../../crowi';
+import { invitedRules, invitedValidation } from '../../middlewares/invited-form-validator';
+
+import { ApiV3Response } from './interfaces/apiv3-response';
+
+type InvitedFormRequest = Request & { form: any, user: any };
+
+module.exports = (crowi: Crowi): Router => {
+  const applicationInstalled = require('../../middlewares/application-installed')(crowi);
+  const debug = require('debug')('growi:routes:login');
+  const User = crowi.model('User');
+  const router = express.Router();
+
+  router.post('/', applicationInstalled, invitedRules(), invitedValidation, async(req: InvitedFormRequest, res: ApiV3Response) => {
+    if (!req.user) {
+      return res.apiv3({ redirectTo: '/login' });
+    }
+
+    if (!req.form.isValid) {
+      return res.apiv3Err(req.form.errors, 400);
+    }
+
+    const user = req.user;
+    const invitedForm = req.form.invitedForm || {};
+    const username = invitedForm.username;
+    const name = invitedForm.name;
+    const password = invitedForm.password;
+
+    // check user upper limit
+    const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
+    if (isUserCountExceedsUpperLimit) {
+      return res.apiv3Err('message.can_not_activate_maximum_number_of_users', 403);
+    }
+
+    const creatable = await User.isRegisterableUsername(username);
+    if (!creatable) {
+      debug('username', username);
+      return res.apiv3Err('message.unable_to_use_this_user', 403);
+    }
+
+    try {
+      await user.activateInvitedUser(username, name, password);
+      return res.apiv3({ redirectTo: '/' });
+    }
+    catch (err) {
+      return res.apiv3Err('message.failed_to_activate', 403);
+    }
+  });
+
+  return router;
+};

+ 41 - 14
packages/app/src/server/routes/forgot-password.ts

@@ -1,12 +1,12 @@
 import {
 import {
-  NextFunction, Request, RequestHandler, Response,
+  NextFunction, Request, Response,
 } from 'express';
 } from 'express';
-
 import createError from 'http-errors';
 import createError from 'http-errors';
 
 
+import { forgotPasswordErrorCode } from '~/interfaces/errors/forgot-password';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import { ReqWithPasswordResetOrder } from '../middlewares/inject-reset-order-by-token-middleware';
+import { IPasswordResetOrder } from '../models/password-reset-order';
 
 
 const logger = loggerFactory('growi:routes:forgot-password');
 const logger = loggerFactory('growi:routes:forgot-password');
 
 
@@ -25,7 +25,7 @@ export const checkForgotPasswordEnabledMiddlewareFactory = (crowi: any, forApi =
       logger.error(message);
       logger.error(message);
 
 
       const statusCode = forApi ? 405 : 404;
       const statusCode = forApi ? 405 : 404;
-      return next(createError(statusCode, message, { code: 'password-reset-is-unavailable' }));
+      return next(createError(statusCode, message, { code: forgotPasswordErrorCode.PASSWORD_RESET_IS_UNAVAILABLE }));
     }
     }
 
 
     next();
     next();
@@ -33,19 +33,46 @@ export const checkForgotPasswordEnabledMiddlewareFactory = (crowi: any, forApi =
 
 
 };
 };
 
 
-export const forgotPassword = (req: Request, res: Response): void => {
-  return res.render('forgot-password');
+type Crowi = {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  nextApp: any,
+}
+
+type CrowiReq = Request & {
+  crowi: Crowi,
+}
+
+export const renderForgotPassword = (crowi: Crowi) => {
+  return (req: CrowiReq, res: Response, next: NextFunction): void => {
+    const { nextApp } = crowi;
+    req.crowi = crowi;
+    nextApp.render(req, res, '/forgot-password');
+    return;
+  };
 };
 };
 
 
-export const resetPassword = (req: ReqWithPasswordResetOrder, res: Response): void => {
-  const { passwordResetOrder } = req;
-  return res.render('reset-password', { email: passwordResetOrder.email });
+export const renderResetPassword = (crowi: Crowi) => {
+  return (req: CrowiReq & { passwordResetOrder: IPasswordResetOrder }, res: Response, next: NextFunction): void => {
+    const { nextApp } = crowi;
+    req.crowi = crowi;
+    nextApp.render(req, res, '/reset-password', { email: req.passwordResetOrder.email });
+    return;
+  };
 };
 };
 
 
 // middleware to handle error
 // middleware to handle error
-export const handleErrosMiddleware = (error: Error & { code: string }, req: Request, res: Response, next: NextFunction): Promise<RequestHandler> | void => {
-  if (error != null) {
-    return res.render('forgot-password/error', { key: error.code });
-  }
-  next(error);
+export const handleErrorsMiddleware = (crowi: Crowi) => {
+  return (error: Error & { code: string, statusCode: number }, req: CrowiReq, res: Response, next: NextFunction): void => {
+    if (error != null) {
+      const { nextApp } = crowi;
+
+      req.crowi = crowi;
+      res.status(error.statusCode);
+
+      nextApp.render(req, res, '/forgot-password-errors', { errorCode: error.code });
+      return;
+    }
+
+    next();
+  };
 };
 };

+ 5 - 4
packages/app/src/server/routes/index.js

@@ -81,7 +81,7 @@ module.exports = function(crowi, app) {
   app.get('/login/error/:reason'      , applicationInstalled, login.error);
   app.get('/login/error/:reason'      , applicationInstalled, login.error);
   app.get('/login'                    , applicationInstalled, login.preLogin, next.delegateToNext);
   app.get('/login'                    , applicationInstalled, login.preLogin, next.delegateToNext);
   app.get('/invited'                  , applicationInstalled, next.delegateToNext);
   app.get('/invited'                  , applicationInstalled, next.delegateToNext);
-  app.post('/invited/activateInvited' , applicationInstalled, loginFormValidator.inviteRules(), loginFormValidator.inviteValidation, csrfProtection, login.invited);
+  // app.post('/login'                   , applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation, csrfProtection,  addActivity, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
 
 
   app.get('/register'                 , applicationInstalled, login.preLogin, login.register);
   app.get('/register'                 , applicationInstalled, login.preLogin, login.register);
 
 
@@ -203,6 +203,7 @@ module.exports = function(crowi, app) {
   // app.get('/tags'                     , loginRequired, tag.showPage);
   // app.get('/tags'                     , loginRequired, tag.showPage);
   app.get('/tags', loginRequired, next.delegateToNext);
   app.get('/tags', loginRequired, next.delegateToNext);
 
 
+  app.get('/me'                                 , loginRequiredStrictly, injectUserUISettings, next.delegateToNext);
   app.get('/me/*'                                 , loginRequiredStrictly, injectUserUISettings, next.delegateToNext);
   app.get('/me/*'                                 , loginRequiredStrictly, injectUserUISettings, next.delegateToNext);
   // external-accounts
   // external-accounts
   // my in-app-notifications
   // my in-app-notifications
@@ -231,9 +232,9 @@ module.exports = function(crowi, app) {
 
 
   app.use('/forgot-password', express.Router()
   app.use('/forgot-password', express.Router()
     .use(forgotPassword.checkForgotPasswordEnabledMiddlewareFactory(crowi))
     .use(forgotPassword.checkForgotPasswordEnabledMiddlewareFactory(crowi))
-    .get('/', forgotPassword.forgotPassword)
-    .get('/:token', injectResetOrderByTokenMiddleware, forgotPassword.resetPassword)
-    .use(forgotPassword.handleErrosMiddleware));
+    .get('/', forgotPassword.renderForgotPassword(crowi))
+    .get('/:token', injectResetOrderByTokenMiddleware, forgotPassword.renderResetPassword(crowi))
+    .use(forgotPassword.handleErrorsMiddleware(crowi)));
 
 
   app.get('/_private-legacy-pages', next.delegateToNext);
   app.get('/_private-legacy-pages', next.delegateToNext);
   app.use('/user-activation', express.Router()
   app.use('/user-activation', express.Router()

+ 6 - 1
packages/app/src/server/routes/login-passport.js

@@ -11,6 +11,7 @@ module.exports = function(crowi, app) {
   const logger = loggerFactory('growi:routes:login-passport');
   const logger = loggerFactory('growi:routes:login-passport');
   const passport = require('passport');
   const passport = require('passport');
   const ExternalAccount = crowi.model('ExternalAccount');
   const ExternalAccount = crowi.model('ExternalAccount');
+  const User = crowi.model('User');
   const passportService = crowi.passportService;
   const passportService = crowi.passportService;
 
 
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
@@ -91,6 +92,7 @@ module.exports = function(crowi, app) {
    * @param {*} res
    * @param {*} res
    */
    */
   const loginSuccessHandler = async(req, res, user, action) => {
   const loginSuccessHandler = async(req, res, user, action) => {
+
     // update lastLoginAt
     // update lastLoginAt
     user.updateLastLoginAt(new Date(), (err, userData) => {
     user.updateLastLoginAt(new Date(), (err, userData) => {
       if (err) {
       if (err) {
@@ -99,7 +101,9 @@ module.exports = function(crowi, app) {
       }
       }
     });
     });
 
 
-    const { redirectTo } = req.session;
+    // check for redirection to '/invited'
+    const redirectTo = req.user.status === User.STATUS_INVITED ? '/invited' : req.session.redirectTo;
+
     // remove session.redirectTo
     // remove session.redirectTo
     delete req.session.redirectTo;
     delete req.session.redirectTo;
 
 
@@ -112,6 +116,7 @@ module.exports = function(crowi, app) {
         username: req.user.username,
         username: req.user.username,
       },
       },
     };
     };
+
     await crowi.activityService.createActivity(parameters);
     await crowi.activityService.createActivity(parameters);
 
 
     return res.apiv3({ redirectTo });
     return res.apiv3({ redirectTo });

+ 5 - 46
packages/app/src/server/routes/login.js

@@ -29,7 +29,11 @@ module.exports = function(crowi, app) {
         });
         });
       }
       }
 
 
-      const { redirectTo } = req.session;
+
+      // userData.password cann't be empty but, prepare redirect because password property in User Model is optional
+      // https://github.com/weseek/growi/pull/6670
+      const redirectTo = userData.password ? req.session.redirectTo : '/me#password';
+
       // remove session.redirectTo
       // remove session.redirectTo
       delete req.session.redirectTo;
       delete req.session.redirectTo;
 
 
@@ -165,50 +169,5 @@ module.exports = function(crowi, app) {
     });
     });
   };
   };
 
 
-  actions.invited = async function(req, res) {
-    if (!req.user) {
-      return res.redirect('/login');
-    }
-
-    if (req.method === 'POST' && req.form.isValid) {
-      const user = req.user;
-      const invitedForm = req.form.invitedForm || {};
-      const username = invitedForm.username;
-      const name = invitedForm.name;
-      const password = invitedForm.password;
-
-      // check user upper limit
-      const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
-      if (isUserCountExceedsUpperLimit) {
-        req.flash('warningMessage', req.t('message.can_not_activate_maximum_number_of_users'));
-        return res.redirect('/invited');
-      }
-
-      const creatable = await User.isRegisterableUsername(username);
-      if (creatable) {
-        try {
-          await user.activateInvitedUser(username, name, password);
-          return res.redirect('/');
-        }
-        catch (err) {
-          req.flash('warningMessage', req.t('message.failed_to_activate'));
-          return res.render('invited');
-        }
-      }
-      else {
-        req.flash('warningMessage', req.t('message.unable_to_use_this_user'));
-        debug('username', username);
-        return res.render('invited');
-      }
-    }
-    else {
-      return res.render('invited');
-    }
-  };
-
-  actions.updateInvitedUser = function(req, res) {
-    return res.redirect('/');
-  };
-
   return actions;
   return actions;
 };
 };

+ 3 - 1
packages/app/src/server/routes/next.ts

@@ -12,7 +12,7 @@ type CrowiReq = Request & {
 }
 }
 
 
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-export default (crowi: Crowi) => {
+const delegator = (crowi: Crowi) => {
 
 
   const { nextApp } = crowi;
   const { nextApp } = crowi;
   const handle = nextApp.getRequestHandler();
   const handle = nextApp.getRequestHandler();
@@ -27,3 +27,5 @@ export default (crowi: Crowi) => {
   };
   };
 
 
 };
 };
+
+export default delegator;

+ 7 - 7
packages/app/src/stores/context.tsx

@@ -1,3 +1,4 @@
+import { IUser } from '@growi/core';
 import { HtmlElementNode } from 'rehype-toc';
 import { HtmlElementNode } from 'rehype-toc';
 import { Key, SWRResponse } from 'swr';
 import { Key, SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
@@ -11,7 +12,6 @@ import { GrowiThemes } from '~/interfaces/theme';
 import InterceptorManager from '~/services/interceptor-manager';
 import InterceptorManager from '~/services/interceptor-manager';
 
 
 import { TargetAndAncestors } from '../interfaces/page-listing-results';
 import { TargetAndAncestors } from '../interfaces/page-listing-results';
-import { IUser, IUserHasId } from '../interfaces/user';
 
 
 import { useStaticSWR } from './use-static-swr';
 import { useStaticSWR } from './use-static-swr';
 
 
@@ -55,8 +55,8 @@ export const useCurrentPagePath = (initialData?: Nullable<string>): SWRResponse<
   return useStaticSWR<Nullable<string>, Error>('currentPagePath', initialData);
   return useStaticSWR<Nullable<string>, Error>('currentPagePath', initialData);
 };
 };
 
 
-export const useCurrentPathname = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
-  return useStaticSWR<Nullable<string>, Error>('currentPathname', initialData);
+export const useCurrentPathname = (initialData?: string): SWRResponse<string, Error> => {
+  return useStaticSWR('currentPathname', initialData);
 };
 };
 
 
 export const useCurrentPageId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
 export const useCurrentPageId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
@@ -98,10 +98,6 @@ export const useIsNotFound = (initialData?: boolean): SWRResponse<boolean, Error
   return useStaticSWR<boolean, Error>('isNotFound', initialData, { fallbackData: false });
   return useStaticSWR<boolean, Error>('isNotFound', initialData, { fallbackData: false });
 };
 };
 
 
-export const usePageUser = (initialData?: IUserHasId): SWRResponse<IUserHasId, Error> => {
-  return useStaticSWR<IUserHasId, Error>('pageUser', initialData);
-};
-
 export const useHasChildren = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const useHasChildren = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
   return useStaticSWR<Nullable<any>, Error>('hasChildren', initialData);
   return useStaticSWR<Nullable<any>, Error>('hasChildren', initialData);
 };
 };
@@ -134,6 +130,10 @@ export const useRevisionIdHackmdSynced = (initialData?: Nullable<any>): SWRRespo
   return useStaticSWR<Nullable<any>, Error>('revisionIdHackmdSynced', initialData);
   return useStaticSWR<Nullable<any>, Error>('revisionIdHackmdSynced', initialData);
 };
 };
 
 
+export const useDrawioUri = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
+  return useStaticSWR<Nullable<string>, Error>('drawioUri', initialData);
+};
+
 export const useHackmdUri = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
 export const useHackmdUri = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
   return useStaticSWR<Nullable<string>, Error>('hackmdUri', initialData);
   return useStaticSWR<Nullable<string>, Error>('hackmdUri', initialData);
 };
 };

+ 97 - 0
packages/app/src/stores/modal.tsx

@@ -5,6 +5,7 @@ import {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
 } from '~/interfaces/ui';
 } from '~/interfaces/ui';
 import { IUserGroupHasId } from '~/interfaces/user';
 import { IUserGroupHasId } from '~/interfaces/user';
+import { dwawioConfig } from '~/utils/drawio-config';
 
 
 import { useStaticSWR } from './use-static-swr';
 import { useStaticSWR } from './use-static-swr';
 
 
@@ -438,3 +439,99 @@ export const useShortcutsModal = (): SWRResponse<ShortcutsModalStatus, Error> &
     },
     },
   };
   };
 };
 };
+
+
+/*
+* DrawioModal
+*/
+
+type DrawioModalStatus = {
+  isOpened: boolean,
+  drawioMxFile: string,
+}
+
+type DrawioModalStatusUtils = {
+  open(drawioMxFile: string): void,
+  close(): void,
+  receiveFromDrawio(event, drawioMxFile: string): void,
+}
+
+export const useDrawioModal = (status?: DrawioModalStatus): SWRResponse<DrawioModalStatus, Error> & DrawioModalStatusUtils => {
+  const initialData: DrawioModalStatus = {
+    isOpened: false,
+    drawioMxFile: '',
+  };
+  const swrResponse = useStaticSWR<DrawioModalStatus, Error>('drawioModalStatus', status, { fallbackData: initialData });
+
+  const close = (): void => {
+    swrResponse.mutate({ isOpened: false, drawioMxFile: '' });
+  };
+
+  const receiveFromDrawio = (event, drawioMxFile: string) => {
+
+    if (event.data === 'ready') {
+      event.source.postMessage(drawioMxFile, '*');
+      return;
+    }
+
+    if (event.data === '{"event":"configure"}') {
+      if (event.source == null) {
+        return;
+      }
+
+      // refs:
+      //  * https://desk.draw.io/support/solutions/articles/16000103852-how-to-customise-the-draw-io-interface
+      //  * https://desk.draw.io/support/solutions/articles/16000042544-how-does-embed-mode-work-
+      //  * https://desk.draw.io/support/solutions/articles/16000058316-how-to-configure-draw-io-
+      event.source.postMessage(JSON.stringify({
+        action: 'configure',
+        config: dwawioConfig,
+      }), '*');
+
+      return;
+    }
+
+    if (typeof event.data === 'string' && event.data.match(/mxfile/)) {
+      if (event.data.length > 0) {
+        const parser = new DOMParser();
+        const dom = parser.parseFromString(event.data, 'text/xml');
+        const drawioData = dom.getElementsByTagName('diagram')[0].innerHTML;
+
+        /*
+        * Saving Drawio will be implemented by the following tasks
+        * https://redmine.weseek.co.jp/issues/100845
+        * https://redmine.weseek.co.jp/issues/104507
+        */
+
+        // if (props.onSave != null) {
+        //   props.onSave(drawioData);
+        // }
+      }
+
+      window.removeEventListener('message', () => receiveFromDrawio);
+      close();
+
+      return;
+    }
+
+    if (typeof event.data === 'string' && event.data.length === 0) {
+      close();
+
+      return;
+    }
+
+    // NOTHING DONE. (Receive unknown iframe message.)
+  };
+
+  const open = (drawioMxFile: string): void => {
+    window.addEventListener('message', e => receiveFromDrawio(e, drawioMxFile));
+    swrResponse.mutate({ isOpened: true, drawioMxFile });
+  };
+
+  return {
+    ...swrResponse,
+    open,
+    close,
+    receiveFromDrawio,
+  };
+};

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

@@ -39,14 +39,16 @@ export const useSWRxPageByPath = (path?: string): SWRResponse<IPagePopulatedToSh
   );
   );
 };
 };
 
 
-export const useSWRxCurrentPage = (shareLinkId?: string, initialData?: IPagePopulatedToShowRevision): SWRResponse<IPagePopulatedToShowRevision|null, Error> => {
+export const useSWRxCurrentPage = (
+    shareLinkId?: string, initialData?: IPagePopulatedToShowRevision|null,
+): SWRResponse<IPagePopulatedToShowRevision|null, Error> => {
   const { data: currentPageId } = useCurrentPageId();
   const { data: currentPageId } = useCurrentPageId();
 
 
   const swrResult = useSWRxPage(currentPageId, shareLinkId);
   const swrResult = useSWRxPage(currentPageId, shareLinkId);
 
 
   // use mutate because fallbackData does not work
   // use mutate because fallbackData does not work
   // see: https://github.com/weseek/growi/commit/5038473e8d6028c9c91310e374a7b5f48b921a15
   // see: https://github.com/weseek/growi/commit/5038473e8d6028c9c91310e374a7b5f48b921a15
-  if (initialData != null) {
+  if (initialData !== undefined) {
     swrResult.mutate(initialData);
     swrResult.mutate(initialData);
   }
   }
 
 

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

@@ -21,7 +21,7 @@ import { UpdateDescCountData } from '~/interfaces/websocket';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import {
 import {
-  useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage, useIsGuestUser,
+  useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsGuestUser,
   useIsSharedUser, useIsIdenticalPath, useCurrentUser, useIsNotFound, useShareLinkId,
   useIsSharedUser, useIsIdenticalPath, useCurrentUser, useIsNotFound, useShareLinkId,
 } from './context';
 } from './context';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
@@ -414,10 +414,11 @@ export const useIsAbleToShowPageManagement = (): SWRResponse<boolean, Error> =>
   const pageId = currentPageId;
   const pageId = currentPageId;
   const includesUndefined = [pageId, isTrashPage, isSharedUser, isNotFound].some(v => v === undefined);
   const includesUndefined = [pageId, isTrashPage, isSharedUser, isNotFound].some(v => v === undefined);
   const isPageExist = (pageId != null) && !isNotFound;
   const isPageExist = (pageId != null) && !isNotFound;
+  const isEmptyPage = (pageId != null) && isNotFound;
 
 
   return useSWRImmutable(
   return useSWRImmutable(
     includesUndefined ? null : [key, pageId],
     includesUndefined ? null : [key, pageId],
-    () => isPageExist && !isTrashPage && !isSharedUser,
+    () => (isPageExist && !isTrashPage && !isSharedUser) || (isEmptyPage != null && isEmptyPage),
   );
   );
 };
 };
 
 
@@ -458,14 +459,15 @@ export const useIsAbleToShowPageEditorModeManager = (): SWRResponse<boolean, Err
 export const useIsAbleToShowPageAuthors = (): SWRResponse<boolean, Error> => {
 export const useIsAbleToShowPageAuthors = (): SWRResponse<boolean, Error> => {
   const key = 'isAbleToShowPageAuthors';
   const key = 'isAbleToShowPageAuthors';
   const { data: pageId } = useCurrentPageId();
   const { data: pageId } = useCurrentPageId();
-  const { data: isUserPage } = useIsUserPage();
+  const { data: pagePath } = useCurrentPagePath();
   const { data: isNotFound } = useIsNotFound();
   const { data: isNotFound } = useIsNotFound();
 
 
-  const includesUndefined = [pageId, isUserPage, isNotFound].some(v => v === undefined);
+  const includesUndefined = [pageId, pagePath, isNotFound].some(v => v === undefined);
   const isPageExist = (pageId != null) && !isNotFound;
   const isPageExist = (pageId != null) && !isNotFound;
+  const isUsersTopPagePath = pagePath != null && isUsersTopPage(pagePath);
 
 
   return useSWRImmutable(
   return useSWRImmutable(
-    includesUndefined ? null : [key, pageId],
-    () => isPageExist && !isUserPage,
+    includesUndefined ? null : [key, pageId, pagePath, isNotFound],
+    () => isPageExist && !isUsersTopPagePath,
   );
   );
 };
 };

+ 3 - 0
packages/app/src/styles/molecules/toastr.scss

@@ -0,0 +1,3 @@
+:root {
+  @import '~toastr/build/toastr';
+}

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

@@ -30,7 +30,8 @@
 @import 'atoms/spinners';
 @import 'atoms/spinners';
 @import 'atoms/custom_control';
 @import 'atoms/custom_control';
 
 
-// // molecules
+// molecules
+@import 'molecules/toastr';
 // @import 'molecules/copy-dropdown';
 // @import 'molecules/copy-dropdown';
 // @import 'molecules/page-editor-mode-manager';
 // @import 'molecules/page-editor-mode-manager';
 // @import 'molecules/slack-notification';
 // @import 'molecules/slack-notification';

+ 15 - 0
packages/app/src/utils/drawio-config.ts

@@ -0,0 +1,15 @@
+const headerColor = '#334455';
+const fontFamily = "Lato, -apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif";
+
+export const dwawioConfig = {
+  css: `
+  .geMenubarContainer { background-color: ${headerColor} !important; }
+  .geMenubar { background-color: ${headerColor} !important; }
+  .geEditor { font-family: ${fontFamily} !important; }
+  html td.mxPopupMenuItem {
+    font-family: ${fontFamily} !important;
+    font-size: 8pt !important;
+  }
+  `,
+  customFonts: ['Lato', 'Charter'],
+};

+ 1 - 1
packages/codemirror-textlint/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/codemirror-textlint",
   "name": "@growi/codemirror-textlint",
-  "version": "5.1.5-RC.0",
+  "version": "6.0.0-RC.1",
   "license": "MIT",
   "license": "MIT",
   "main": "dist/index.js",
   "main": "dist/index.js",
   "scripts": {
   "scripts": {

+ 1 - 1
packages/core/package.json

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

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

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

+ 6 - 11
packages/plugin-lsx/package.json

@@ -1,12 +1,9 @@
 {
 {
   "name": "@growi/plugin-lsx",
   "name": "@growi/plugin-lsx",
-  "version": "5.1.5-RC.0",
+  "version": "6.0.0-RC.1",
   "description": "GROWI plugin to list pages",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "license": "MIT",
-  "keywords": [
-    "growi",
-    "growi-plugin"
-  ],
+  "keywords": ["growi", "growi-plugin"],
   "main": "dist/cjs/index.js",
   "main": "dist/cjs/index.js",
   "module": "dist/esm/index.js",
   "module": "dist/esm/index.js",
   "exports": {
   "exports": {
@@ -14,9 +11,7 @@
     "./services/renderer": "./dist/cjs/services/renderer/index.js",
     "./services/renderer": "./dist/cjs/services/renderer/index.js",
     "./server/routes": "./dist/cjs/server/routes/index.js"
     "./server/routes": "./dist/cjs/server/routes/index.js"
   },
   },
-  "files": [
-    "dist"
-  ],
+  "files": ["dist"],
   "scripts": {
   "scripts": {
     "build": "run-p build:*",
     "build": "run-p build:*",
     "build:cjs": "tsc -p tsconfig.build.cjs.json && tsc-alias -p tsconfig.build.cjs.json",
     "build:cjs": "tsc -p tsconfig.build.cjs.json && tsc-alias -p tsconfig.build.cjs.json",
@@ -28,9 +23,9 @@
     "test": ""
     "test": ""
   },
   },
   "dependencies": {
   "dependencies": {
-    "@growi/core": "^5.1.5-RC.0",
-    "@growi/remark-growi-plugin": "^5.1.5-RC.0",
-    "@growi/ui": "^5.1.5-RC.0"
+    "@growi/core": "^6.0.0-RC.1",
+    "@growi/remark-growi-plugin": "^6.0.0-RC.1",
+    "@growi/ui": "^6.0.0-RC.1"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "eslint-plugin-regex": "^1.8.0",
     "eslint-plugin-regex": "^1.8.0",

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

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

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/slack",
   "name": "@growi/slack",
-  "version": "5.1.5-RC.0",
+  "version": "6.0.0-RC.1",
   "license": "MIT",
   "license": "MIT",
   "main": "dist/index.js",
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",
   "typings": "dist/index.d.ts",

+ 2 - 2
packages/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/slackbot-proxy",
   "name": "@growi/slackbot-proxy",
-  "version": "5.1.5-slackbot-proxy.0",
+  "version": "6.0.0-slackbot-proxy.1",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -26,7 +26,7 @@
   },
   },
   "dependencies": {
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^5.1.5-RC.0",
+    "@growi/slack": "^6.0.0-RC.1",
     "@slack/oauth": "^2.0.1",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",
     "@tsed/common": "^6.43.0",

+ 4 - 8
packages/ui/package.json

@@ -1,16 +1,12 @@
 {
 {
   "name": "@growi/ui",
   "name": "@growi/ui",
-  "version": "5.1.5-RC.0",
+  "version": "6.0.0-RC.1",
   "description": "GROWI UI Libraries",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "license": "MIT",
-  "keywords": [
-    "growi"
-  ],
+  "keywords": ["growi"],
   "main": "dist/cjs/index.js",
   "main": "dist/cjs/index.js",
   "module": "dist/esm/index.js",
   "module": "dist/esm/index.js",
-  "files": [
-    "dist"
-  ],
+  "files": ["dist"],
   "scripts": {
   "scripts": {
     "build": "run-p build:*",
     "build": "run-p build:*",
     "build:cjs": "tsc -p tsconfig.build.cjs.json && tsc-alias -p tsconfig.build.cjs.json",
     "build:cjs": "tsc -p tsconfig.build.cjs.json && tsc-alias -p tsconfig.build.cjs.json",
@@ -21,7 +17,7 @@
     "test": "jest --verbose"
     "test": "jest --verbose"
   },
   },
   "dependencies": {
   "dependencies": {
-    "@growi/core": "^5.1.5-RC.0"
+    "@growi/core": "^6.0.0-RC.1"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "eslint-plugin-regex": "^1.8.0",
     "eslint-plugin-regex": "^1.8.0",