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

Merge branch 'support/apply-nextjs-2' of https://github.com/weseek/growi into fix/105954-global-search-is-not-displayed

Shun Miyazawa 3 лет назад
Родитель
Сommit
487efc5438
62 измененных файлов с 833 добавлено и 560 удалено
  1. 1 1
      packages/app/config/rate-limiter.ts
  2. 6 12
      packages/app/public/static/locales/en_US/translation.json
  3. 6 12
      packages/app/public/static/locales/ja_JP/translation.json
  4. 6 12
      packages/app/public/static/locales/zh_CN/translation.json
  5. 72 12
      packages/app/src/components/InvitedForm.tsx
  6. 2 0
      packages/app/src/components/Layout/BasicLayout.tsx
  7. 76 23
      packages/app/src/components/LoginForm.tsx
  8. 1 1
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  9. 43 35
      packages/app/src/components/Page.tsx
  10. 120 45
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  11. 0 172
      packages/app/src/components/PageEditor/DrawioModal.jsx
  12. 77 0
      packages/app/src/components/PageEditor/DrawioModal.tsx
  13. 9 3
      packages/app/src/components/PageEditor/Editor.tsx
  14. 9 13
      packages/app/src/components/PageEditor/EmojiPicker.tsx
  15. 28 39
      packages/app/src/components/PageEditor/EmojiPickerHelper.ts
  16. 5 0
      packages/app/src/interfaces/errors/login-error.ts
  17. 3 0
      packages/app/src/interfaces/errors/v3-error.ts
  18. 0 7
      packages/app/src/interfaces/services/renderer.ts
  19. 6 7
      packages/app/src/pages/[[...path]].page.tsx
  20. 10 2
      packages/app/src/pages/login.page.tsx
  21. 39 5
      packages/app/src/pages/share/[[...path]].page.tsx
  22. 1 1
      packages/app/src/pages/utils/commons.ts
  23. 2 2
      packages/app/src/server/middlewares/apiv3-form-validator.ts
  24. 45 0
      packages/app/src/server/middlewares/invited-form-validator.ts
  25. 0 43
      packages/app/src/server/middlewares/login-form-validator.ts
  26. 7 12
      packages/app/src/server/models/vo/error-v3.js
  27. 2 3
      packages/app/src/server/routes/apiv3/app-settings.js
  28. 2 2
      packages/app/src/server/routes/apiv3/attachment.js
  29. 2 2
      packages/app/src/server/routes/apiv3/customize-setting.js
  30. 1 1
      packages/app/src/server/routes/apiv3/forgot-password.js
  31. 2 1
      packages/app/src/server/routes/apiv3/healthcheck.js
  32. 1 2
      packages/app/src/server/routes/apiv3/import.js
  33. 2 0
      packages/app/src/server/routes/apiv3/index.js
  34. 1 1
      packages/app/src/server/routes/apiv3/installer.ts
  35. 53 0
      packages/app/src/server/routes/apiv3/invited.ts
  36. 2 2
      packages/app/src/server/routes/apiv3/markdown-setting.js
  37. 2 2
      packages/app/src/server/routes/apiv3/notification-setting.js
  38. 1 1
      packages/app/src/server/routes/apiv3/page-listing.ts
  39. 3 2
      packages/app/src/server/routes/apiv3/page.js
  40. 2 2
      packages/app/src/server/routes/apiv3/pages.js
  41. 2 1
      packages/app/src/server/routes/apiv3/response.js
  42. 3 2
      packages/app/src/server/routes/apiv3/revisions.js
  43. 2 2
      packages/app/src/server/routes/apiv3/search.js
  44. 2 2
      packages/app/src/server/routes/apiv3/security-setting.js
  45. 2 2
      packages/app/src/server/routes/apiv3/share-links.js
  46. 2 2
      packages/app/src/server/routes/apiv3/slack-integration-legacy-settings.js
  47. 1 2
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  48. 1 2
      packages/app/src/server/routes/apiv3/slack-integration.js
  49. 2 1
      packages/app/src/server/routes/apiv3/user-activation.ts
  50. 2 1
      packages/app/src/server/routes/apiv3/user-group-relation.js
  51. 2 1
      packages/app/src/server/routes/apiv3/user-group.js
  52. 2 3
      packages/app/src/server/routes/apiv3/user-ui-settings.ts
  53. 2 1
      packages/app/src/server/routes/apiv3/users.js
  54. 1 1
      packages/app/src/server/routes/index.js
  55. 23 11
      packages/app/src/server/routes/login-passport.js
  56. 0 45
      packages/app/src/server/routes/login.js
  57. 4 0
      packages/app/src/stores/context.tsx
  58. 97 0
      packages/app/src/stores/modal.tsx
  59. 2 1
      packages/app/src/stores/ui.tsx
  60. 15 0
      packages/app/src/utils/drawio-config.ts
  61. 1 0
      packages/core/src/index.ts
  62. 15 0
      packages/core/src/models/vo/error-apiv3.ts

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

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

+ 6 - 12
packages/app/public/static/locales/en_US/translation.json

@@ -674,7 +674,9 @@
   "login": {
     "Sign in error": "Login error",
     "Registration successful": "Registration successful",
-    "Setup": "Setup"
+    "Setup": "Setup",
+    "enabled_ldap_has_configuration_problem":"LDAP is enabled but the configuration has something wrong.",
+    "set_env_var_for_logs": "(Please set the environment variables <code>DEBUG=crowi:service:PassportService</code> to get the logs)"
   },
   "invited": {
     "discription_heading": "Create Account",
@@ -691,16 +693,7 @@
     "fail_to_save_access_token": "Failed to save access_token. Please try again.",
     "fail_to_fetch_access_token": "Failed to fetch access_token. Please do connect again.",
     "successfully_disconnected": "Successfully Disconnected!",
-    "strategy_has_not_been_set_up": {
-      "LocalStrategy"  : "LocalStrategy has not been set up",
-      "LdapStrategy"   : "LdapStrategy has not been set up",
-      "GoogleStrategy" : "GoogleStrategy has not been set up",
-      "GitHubStrategy" : "GitHubStrategy has not been set up",
-      "TwitterStrategy": "TwitterStrategy has not been set up",
-      "OidcStrategy"   : "OidcStrategy has not been set up",
-      "SamlStrategy"   : "SamlStrategy has not been set up",
-      "Basic"          : "Basic has not been set up"
-    },
+    "strategy_has_not_been_set_up": "{{strategy}} has not been set up",
     "ldap_user_not_valid": "Ldap user is no valid",
     "external_account_not_exist": "Failed to find or create External account",
     "maximum_number_of_users": "Can not register more than the maximum number of users.",
@@ -734,7 +727,8 @@
     "Password field is required": "Password field is required.",
     "Username or E-mail has invalid characters": "Username or E-mail has invalid characters.",
     "Password minimum character should be more than 6 characters": "Password minimum character should be more than 6 characters.",
-    "user_not_found": "User not found."
+    "user_not_found": "User not found.",
+    "provider_duplicated_username_exception": "<p><strong><i class='icon-fw icon-ban'></i>DuplicatedUsernameException occured</strong></p><p class='mb-0'> Your {{ failedProviderForDuplicatedUsernameException }} authentication was succeeded, but a new user could not be created. See the issue <a href='https://github.com/weseek/growi/issues/193'>#193</a>.</p>"
   },
   "grid_edit":{
     "create_bootstrap_4_grid":"Create Bootstrap 4 Grid",

+ 6 - 12
packages/app/public/static/locales/ja_JP/translation.json

@@ -665,7 +665,9 @@
   "login": {
     "Sign in error": "ログインエラー",
     "Registration successful": "登録完了",
-    "Setup": "セットアップ"
+    "Setup": "セットアップ",
+    "enabled_ldap_has_configuration_problem":"LDAPは有効ですが、設定に問題があります。",
+    "set_env_var_for_logs": "(ログを取得するためには、環境変数 <code>DEBUG=crowi:service:PassportService</code> を設定してください。)"
   },
   "invited": {
     "discription_heading": "アカウント作成",
@@ -682,16 +684,7 @@
     "fail_to_save_access_token": "アクセストークンの保存に失敗しました、再度お試しください。",
     "fail_to_fetch_access_token": "アクセストークンの取得に失敗しました、再度お試しください。",
     "successfully_disconnected": "切断に成功しました!",
-    "strategy_has_not_been_set_up": {
-      "LocalStrategy"  : "LocalStrategy はセットアップされていません。",
-      "LdapStrategy"   : "LdapStrategy はセットアップされていません。",
-      "GoogleStrategy" : "GoogleStrategy はセットアップされていません。",
-      "GitHubStrategy" : "GitHubStrategy はセットアップされていません。",
-      "TwitterStrategy": "TwitterStrategy はセットアップされていません。",
-      "OidcStrategy"   : "OidcStrategy はセットアップされていません。",
-      "SamlStrategy"   : "SamlStrategy はセットアップされていません。",
-      "Basic"          : "Basic はセットアップされていません。"
-    },
+    "strategy_has_not_been_set_up": "{{strategy}} はセットアップされていません。",
     "ldap_user_not_valid": "Ldap user is no valid",
     "external_account_not_exist": "外部アカウントが見つからない、または作成に失敗しました",
     "maximum_number_of_users": "ユーザー数が上限を超えたためアクティベートできません。",
@@ -725,7 +718,8 @@
     "Password field is required": "パスワードの欄は必ず入力してください",
     "Username or E-mail has invalid characters": "ユーザー名または、メールアドレスに無効な文字があります",
     "Password minimum character should be more than 6 characters": "パスワードの最小文字数は6文字以上です",
-    "user_not_found": "ユーザーが見つかりません"
+    "user_not_found": "ユーザーが見つかりません",
+    "provider_duplicated_username_exception": "<p><strong><i class='icon-fw icon-ban'></i>エラー: DuplicatedUsernameException</strong></p><p class='mb-0'> {{ failedProviderForDuplicatedUsernameException }} 認証は成功しましたが、新しいユーザーを作成できませんでした。詳しくは<a href='https://github.com/weseek/growi/issues/193'>こちら: #193</a>.</p>"
   },
   "grid_edit":{
     "create_bootstrap_4_grid":"Bootstrap 4 グリッドを作成",

+ 6 - 12
packages/app/public/static/locales/zh_CN/translation.json

@@ -721,7 +721,9 @@
 	"login": {
 		"Sign in error": "登录错误",
 		"Registration successful": "注册成功",
-		"Setup": "安装程序"
+		"Setup": "安装程序",
+    "enabled_ldap_has_configuration_problem":"启用了LDAP,但配置有问题。",
+    "set_env_var_for_logs": "(请设置环境变量 <code>DEBUG=crowi:service:PassportService</code> 以获得日志。)"
 	},
   "invited": {
     "discription_heading": "创建账户",
@@ -738,16 +740,7 @@
 		"fail_to_save_access_token": "无法保存访问令牌。请再试一次。",
 		"fail_to_fetch_access_token": "无法获取访问令牌。请重新连接。",
 		"successfully_disconnected": "成功断开连接!",
-    "strategy_has_not_been_set_up": {
-      "LocalStrategy"  : "LocalStrategy 尚未设置",
-      "LdapStrategy"   : "LdapStrategy 尚未设置",
-      "GoogleStrategy" : "GoogleStrategy 尚未设置",
-      "GitHubStrategy" : "GitHubStrategy 尚未设置",
-      "TwitterStrategy": "TwitterStrategy 尚未设置",
-      "OidcStrategy"   : "OidcStrategy 尚未设置",
-      "SamlStrategy"   : "SamlStrategy 尚未设置",
-      "Basic"          : "Basic 尚未设置"
-     },
+    "strategy_has_not_been_set_up": "{{strategy}} 尚未设置",
     "ldap_user_not_valid": "Ldap user is no valid",
     "external_account_not_exist": "查找或创建外部账户失败",
 		"maximum_number_of_users": "注册的用户数不能超过最大值。",
@@ -781,7 +774,8 @@
     "Password field is required": "密码字段是必需的",
     "Username or E-mail has invalid characters": "用户名或电子邮件有无效的字符",
     "Password minimum character should be more than 6 characters": "密码最小字符应超过6个字符",
-    "user_not_found": "未找到用户"
+    "user_not_found": "未找到用户",
+    "provider_duplicated_username_exception": "<p><strong><i class='icon-fw icon-ban'></i>发生了重复用户名异常</strong></p><p class='mb-0'> 你的 {{ failedProviderForDuplicatedUsernameException }} 认证成功了,但不能创建新的用户。参见问题<a href='https://github.com/weseek/growi/issues/193'>#193</a>.</p>"
 	},
   "grid_edit":{
     "create_bootstrap_4_grid":"创建Bootstrap 4网格",

+ 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 { 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 = {
   invitedFormUsername: string,
@@ -10,23 +14,79 @@ export type InvitedFormProps = {
 }
 
 export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
+
   const { t } = useTranslation();
-  const { data: csrfToken } = useCsrfToken();
+  const router = useRouter();
   const { data: user } = useCurrentUser();
+  const [isConnectSuccess, setIsConnectSuccess] = useState<boolean>(false);
+  const [loginErrors, setLoginErrors] = useState<Error[]>([]);
 
   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) {
     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 */}
         <div className="input-group">
           <div className="input-group-prepend">
@@ -89,11 +149,11 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
             placeholder={t('Password')}
             name="invitedForm[password]"
             required
+            minLength={6}
           />
         </div>
         {/* 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">
             <div className="eff"></div>
             <span className="btn-label"><i className="icon-user-follow"></i></span>
@@ -101,7 +161,7 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
           </button>
         </div>
       </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">
           <span className="growi">GROWI</span>.<span className="org">ORG</span>
         </a>

+ 2 - 0
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 PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
 const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal'), { ssr: false });
+const DrawioModal = dynamic(() => import('../PageEditor/DrawioModal').then(mod => mod.DrawioModal), { ssr: false });
 // Fab
 const Fab = dynamic(() => import('../Fab').then(mod => mod.Fab), { ssr: false });
 
@@ -58,6 +59,7 @@ export const BasicLayout = ({
       <PageRenameModal />
       <PagePresentationModal />
       <PageAccessoriesModal />
+      <DrawioModal />
       <HotkeysManager />
 
       <Fab />

+ 76 - 23
packages/app/src/components/LoginForm.tsx

@@ -7,6 +7,8 @@ import { useRouter } from 'next/router';
 import ReactCardFlip from 'react-card-flip';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
+import { LoginErrorCode } from '~/interfaces/errors/login-error';
+import { IErrorV3 } from '~/interfaces/errors/v3-error';
 
 type LoginFormProps = {
   username?: string,
@@ -19,6 +21,7 @@ type LoginFormProps = {
   isPasswordResetEnabled: boolean,
   isLocalStrategySetup: boolean,
   isLdapStrategySetup: boolean,
+  isLdapSetupFailed: boolean,
   objOfIsExternalAuthEnableds?: any,
   isMailerSetup?: boolean
 }
@@ -27,7 +30,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
   const router = useRouter();
 
   const {
-    isLocalStrategySetup, isLdapStrategySetup, isPasswordResetEnabled, isRegistrationEnabled,
+    isLocalStrategySetup, isLdapStrategySetup, isLdapSetupFailed, isPasswordResetEnabled, isRegistrationEnabled,
     isEmailAuthenticationEnabled, registrationMode, registrationWhiteList, isMailerSetup, objOfIsExternalAuthEnableds,
   } = props;
   const isLocalOrLdapStrategiesEnabled = isLocalStrategySetup || isLdapStrategySetup;
@@ -38,13 +41,13 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
   // For Login
   const [usernameForLogin, setUsernameForLogin] = useState('');
   const [passwordForLogin, setPasswordForLogin] = useState('');
-  const [loginErrors, setLoginErrors] = useState<Error[]>([]);
+  const [loginErrors, setLoginErrors] = useState<IErrorV3[]>([]);
   // For Register
   const [usernameForRegister, setUsernameForRegister] = useState('');
   const [nameForRegister, setNameForRegister] = useState('');
   const [emailForRegister, setEmailForRegister] = useState('');
   const [passwordForRegister, setPasswordForRegister] = useState('');
-  const [registerErrors, setRegisterErrors] = useState<Error[]>([]);
+  const [registerErrors, setRegisterErrors] = useState<IErrorV3[]>([]);
 
   useEffect(() => {
     const { hash } = window.location;
@@ -60,8 +63,14 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     window.location.href = `/passport/${auth}`;
   }, []);
 
+  const resetLoginErrors = useCallback(() => {
+    if (loginErrors.length === 0) return;
+    setLoginErrors([]);
+  }, [loginErrors.length]);
+
   const handleLoginWithLocalSubmit = useCallback(async(e) => {
     e.preventDefault();
+    resetLoginErrors();
 
     const loginForm = {
       username: usernameForLogin,
@@ -78,25 +87,73 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     }
     return;
 
-  }, [passwordForLogin, router, usernameForLogin]);
+  }, [passwordForLogin, resetLoginErrors, router, usernameForLogin]);
+
+  // separate errors based on error code
+  const separateErrorsBasedOnErrorCode = useCallback((errors: IErrorV3[]) => {
+    const loginErrorListForDangerouslySetInnerHTML: IErrorV3[] = [];
+    const loginErrorList: IErrorV3[] = [];
+
+    errors.forEach((err) => {
+      if (err.code === LoginErrorCode.PROVIDER_DUPLICATED_USERNAME_EXCEPTION) {
+        loginErrorListForDangerouslySetInnerHTML.push(err);
+      }
+      else {
+        loginErrorList.push(err);
+      }
+    });
+
+    return [loginErrorListForDangerouslySetInnerHTML, loginErrorList];
+  }, []);
+
+  // wrap error elements which use dangerouslySetInnerHtml
+  const generateDangerouslySetErrors = useCallback((errors: IErrorV3[]): JSX.Element => {
+    if (errors == null || errors.length === 0) return <></>;
+    return (
+      <div className="alert alert-danger">
+        {errors.map((err, index) => {
+          return <small key={index} dangerouslySetInnerHTML={{ __html: t(err.message, err.args) }}></small>;
+        })}
+      </div>
+    );
+  }, [t]);
+
+  // wrap error elements which do not use dangerouslySetInnerHtml
+  const generateSafelySetErrors = useCallback((errors: IErrorV3[]): JSX.Element => {
+    if (errors == null || errors.length === 0) return <></>;
+    return (
+      <ul className="alert alert-danger">
+        {errors.map((err, index) => {
+          return (
+            <li key={index}>
+              {t(err.message, err.args)}<br/>
+            </li>);
+        })}
+      </ul>
+    );
+  }, [t]);
 
   const renderLocalOrLdapLoginForm = useCallback(() => {
     const { isLdapStrategySetup } = props;
+
+    // separate login errors into two arrays based on error code
+    const [loginErrorListForDangerouslySetInnerHTML, loginErrorList] = separateErrorsBasedOnErrorCode(loginErrors);
+    // Generate login error elements using dangerouslySetInnerHTML
+    const loginErrorElementWithDangerouslySetInnerHTML = generateDangerouslySetErrors(loginErrorListForDangerouslySetInnerHTML);
+    // Generate login error elements using <ul>, <li>
+    const loginErrorElement = generateSafelySetErrors(loginErrorList);
+
     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>
-          )
-        }
+        {isLdapSetupFailed && (
+          <div className="alert alert-warning small">
+            <strong><i className="icon-fw icon-info"></i>{t('login.enabled_ldap_has_configuration_problem')}</strong><br/>
+            <span dangerouslySetInnerHTML={{ __html: t('login.set_env_var_for_logs') }}></span>
+          </div>
+        )}
+        {loginErrorElementWithDangerouslySetInnerHTML}
+        {loginErrorElement}
+
         <form role="form" onSubmit={handleLoginWithLocalSubmit} id="login-form">
           <div className="input-group">
             <div className="input-group-prepend">
@@ -137,7 +194,8 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
         </form>
       </>
     );
-  }, [handleLoginWithLocalSubmit, loginErrors, props, t]);
+  }, [generateDangerouslySetErrors, generateSafelySetErrors, handleLoginWithLocalSubmit,
+      isLdapSetupFailed, loginErrors, props, separateErrorsBasedOnErrorCode, t]);
 
   const renderExternalAuthInput = useCallback((auth) => {
     const authIconNames = {
@@ -222,11 +280,6 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     return;
   }, [emailForRegister, nameForRegister, passwordForRegister, router, usernameForRegister]);
 
-  const resetLoginErrors = useCallback(() => {
-    if (loginErrors.length === 0) return;
-    setLoginErrors([]);
-  }, [loginErrors.length]);
-
   const resetRegisterErrors = useCallback(() => {
     if (registerErrors.length === 0) return;
     setRegisterErrors([]);

+ 1 - 1
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -323,7 +323,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
         <div className="d-flex">
           <div className="d-flex flex-column align-items-end justify-content-center py-md-2" style={{ gap: `${isCompactMode ? '5px' : '7px'}` }}>
             { isViewMode && (
-              <div className="h-50 w-100">
+              <div className="h-50">
                 { pageId != null && (
                   <SubNavButtons
                     isCompactMode={isCompactMode}

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

@@ -3,12 +3,14 @@ import React, {
   useEffect, useRef, useState,
 } from 'react';
 
+import EventEmitter from 'events';
+
 import dynamic from 'next/dynamic';
 // import { debounce } from 'throttle-debounce';
 
 import { HtmlElementNode } from 'rehype-toc';
 
-// import { getOptionsToSave } from '~/client/util/editor';
+import { getOptionsToSave } from '~/client/util/editor';
 import {
   useIsGuestUser, useCurrentPageTocNode, useShareLinkId,
 } from '~/stores/context';
@@ -23,11 +25,13 @@ import {
 import loggerFactory from '~/utils/logger';
 
 import RevisionRenderer from './Page/RevisionRenderer';
-
-// TODO: import dynamically
+import { DrawioModal } from './PageEditor/DrawioModal';
 // 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 GridEditModal = dynamic(() => import('./PageEditor/GridEditModal'), { ssr: false });
@@ -132,35 +136,35 @@ class PageSubstance extends React.Component<PageSubstanceProps> {
   }
 
   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() {
@@ -182,7 +186,11 @@ class PageSubstance extends React.Component<PageSubstanceProps> {
             <GridEditModal ref={this.gridEditModal} />
             <LinkEditModal ref={this.linkEditModal} />
             {/* <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>

+ 120 - 45
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 { commands } from 'codemirror';
@@ -7,16 +7,18 @@ import * as loadCssSync from 'load-css-file';
 import PropTypes from 'prop-types';
 import { Button } from 'reactstrap';
 import * as loadScript from 'simple-load-script';
+import { throttle, debounce } from 'throttle-debounce';
 import urljoin from 'url-join';
 
 import InterceptorManager from '~/services/interceptor-manager';
+import { useDrawioModal } from '~/stores/modal';
 import loggerFactory from '~/utils/logger';
 
 import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 
 import AbstractEditor from './AbstractEditor';
 import CommentMentionHelper from './CommentMentionHelper';
-// import DrawioModal from './DrawioModal';
+import { DrawioModal } from './DrawioModal';
 import EditorIcon from './EditorIcon';
 import EmojiPicker from './EmojiPicker';
 import EmojiPickerHelper from './EmojiPickerHelper';
@@ -105,7 +107,9 @@ class CodeMirrorEditor extends AbstractEditor {
       isCheatsheetModalShown: false,
       additionalClassSet: new Set(),
       isEmojiPickerShown: false,
-      emojiSearchText: null,
+      emojiSearchText: '',
+      startPosWithEmojiPickerModeTurnedOn: null,
+      isEmojiPickerMode: false,
     };
 
     this.cm = React.createRef();
@@ -131,7 +135,16 @@ class CodeMirrorEditor extends AbstractEditor {
     this.pasteHandler = this.pasteHandler.bind(this);
     this.cursorHandler = this.cursorHandler.bind(this);
     this.changeHandler = this.changeHandler.bind(this);
-    this.keyUpHandler = this.keyUpHandler.bind(this);
+    this.turnOnEmojiPickerMode = this.turnOnEmojiPickerMode.bind(this);
+    this.turnOffEmojiPickerMode = this.turnOffEmojiPickerMode.bind(this);
+    this.windowClickHandler = this.windowClickHandler.bind(this);
+    this.keyDownHandler = this.keyDownHandler.bind(this);
+    this.keyDownHandlerForEmojiPicker = this.keyDownHandlerForEmojiPicker.bind(this);
+    this.keyDownHandlerForEmojiPickerThrottled = throttle(400, this.keyDownHandlerForEmojiPicker);
+    this.showEmojiPicker = this.showEmojiPicker.bind(this);
+    this.keyPressHandlerForEmojiPicker = this.keyPressHandlerForEmojiPicker.bind(this);
+    this.keyPressHandlerForEmojiPickerThrottled = debounce(50, throttle(200, this.keyPressHandlerForEmojiPicker));
+    this.keyPressHandler = this.keyPressHandler.bind(this);
 
     this.updateCheatsheetStates = this.updateCheatsheetStates.bind(this);
 
@@ -142,11 +155,9 @@ class CodeMirrorEditor extends AbstractEditor {
     this.showGridEditorHandler = this.showGridEditorHandler.bind(this);
     this.showLinkEditHandler = this.showLinkEditHandler.bind(this);
     this.showHandsonTableHandler = this.showHandsonTableHandler.bind(this);
-    this.showDrawioHandler = this.showDrawioHandler.bind(this);
 
     this.foldDrawioSection = this.foldDrawioSection.bind(this);
     this.onSaveForDrawio = this.onSaveForDrawio.bind(this);
-    this.checkWhetherEmojiPickerShouldBeShown = this.checkWhetherEmojiPickerShouldBeShown.bind(this);
 
   }
 
@@ -180,6 +191,13 @@ class CodeMirrorEditor extends AbstractEditor {
     }
     this.emojiPickerHelper = new EmojiPickerHelper(this.getCodeMirror());
 
+    // HACKME: Find a better way to handle onClick for Editor
+    document.addEventListener('click', this.windowClickHandler);
+  }
+
+  componentWillUnmount() {
+    // HACKME: Find a better way to handle onClick for Editor
+    document.removeEventListener('click', this.windowClickHandler);
   }
 
   componentWillReceiveProps(nextProps) {
@@ -583,12 +601,84 @@ class CodeMirrorEditor extends AbstractEditor {
 
   }
 
-  keyUpHandler(editor, event) {
-    if (event.key !== 'Backspace') {
-      this.checkWhetherEmojiPickerShouldBeShown();
+  turnOnEmojiPickerMode(pos) {
+    this.setState({
+      isEmojiPickerMode: true,
+      startPosWithEmojiPickerModeTurnedOn: pos,
+    });
+  }
+
+  turnOffEmojiPickerMode() {
+    this.setState({
+      isEmojiPickerMode: false,
+    });
+  }
+
+  showEmojiPicker(initialSearchingText) {
+    // show emoji picker with a stored word
+    this.setState({
+      isEmojiPickerShown: true,
+      emojiSearchText: initialSearchingText ?? '',
+    });
+
+    const resetStartPos = initialSearchingText == null;
+    if (resetStartPos) {
+      this.setState({ startPosWithEmojiPickerModeTurnedOn: null });
+    }
+
+    this.turnOffEmojiPickerMode();
+  }
+
+  keyPressHandlerForEmojiPicker(editor, event) {
+    const char = event.key;
+    const isEmojiPickerMode = this.state.isEmojiPickerMode;
+
+    // evaluate whether emoji picker mode to be turned on
+    if (!isEmojiPickerMode) {
+      const startPos = this.emojiPickerHelper.shouldModeTurnOn(char);
+      if (startPos == null) {
+        return;
+      }
+
+      this.turnOnEmojiPickerMode(startPos);
+      return;
+    }
+
+    // evaluate whether EmojiPicker to be opened
+    const startPos = this.state.startPosWithEmojiPickerModeTurnedOn;
+    if (this.emojiPickerHelper.shouldOpen(startPos)) {
+      const initialSearchingText = this.emojiPickerHelper.getInitialSearchingText(startPos);
+      this.showEmojiPicker(initialSearchingText);
+      return;
+    }
+
+    this.turnOffEmojiPickerMode();
+  }
+
+  keyPressHandler(editor, event) {
+    this.keyPressHandlerForEmojiPickerThrottled(editor, event);
+  }
+
+  keyDownHandlerForEmojiPicker(editor, event) {
+    const key = event.key;
+
+    if (!this.state.isEmojiPickerMode) {
+      return;
+    }
+
+    if (['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'BackSpace'].includes(key)) {
+      this.turnOffEmojiPickerMode();
     }
   }
 
+  keyDownHandler(editor, event) {
+    this.keyDownHandlerForEmojiPickerThrottled(editor, event);
+  }
+
+  windowClickHandler() {
+    this.turnOffEmojiPickerMode();
+  }
+
   /**
    * CodeMirror paste event handler
    * see: https://codemirror.net/doc/manual.html#events
@@ -610,26 +700,6 @@ class CodeMirrorEditor extends AbstractEditor {
 
   }
 
-  /**
-   * Show emoji picker component when emoji pattern (`:` + searchWord ) found
-   * eg `:a`, `:ap`
-   */
-  checkWhetherEmojiPickerShouldBeShown() {
-    const searchWord = this.emojiPickerHelper.getEmoji();
-
-    if (searchWord == null) {
-      this.setState({ isEmojiPickerShown: false });
-      this.setState({ emojiSearchText: null });
-    }
-    else {
-      this.setState({ emojiSearchText: searchWord });
-      // Show emoji picker after user stop typing
-      setTimeout(() => {
-        this.setState({ isEmojiPickerShown: true });
-      }, 700);
-    }
-  }
-
   /**
    * update states which related to cheatsheet
    * @param {boolean} isGfmModeTmp (use state.isGfmMode if null is set)
@@ -709,7 +779,8 @@ class CodeMirrorEditor extends AbstractEditor {
         <div className="text-left">
           <div className="mb-2 d-none d-md-block">
             <EmojiPicker
-              onClose={() => this.setState({ isEmojiPickerShown: false, emojiSearchText: null })}
+              onClose={() => this.setState({ isEmojiPickerShown: false })}
+              onSelected={emoji => this.emojiPickerHelper.addEmoji(emoji, this.state.startPosWithEmojiPickerModeTurnedOn)}
               emojiSearchText={emojiSearchText}
               emojiPickerHelper={this.emojiPickerHelper}
               isOpen={this.state.isEmojiPickerShown}
@@ -799,10 +870,6 @@ class CodeMirrorEditor extends AbstractEditor {
     // this.handsontableModal.current.show(mtu.getMarkdownTable(this.getCodeMirror()));
   }
 
-  showDrawioHandler() {
-    // this.drawioModal.current.show(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()));
-  }
-
 
   // fold draw.io section (::: drawio ~ :::)
   foldDrawioSection() {
@@ -954,7 +1021,7 @@ class CodeMirrorEditor extends AbstractEditor {
         color={null}
         bssize="small"
         title="draw.io"
-        onClick={this.showDrawioHandler}
+        onClick={() => this.props.onClickDrawioBtn(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()))}
       >
         <EditorIcon icon="Drawio" />
       </Button>,
@@ -963,7 +1030,7 @@ class CodeMirrorEditor extends AbstractEditor {
         color={null}
         bssize="small"
         title="Emoji"
-        onClick={() => this.setState({ isEmojiPickerShown: true })}
+        onClick={() => this.showEmojiPicker()}
       >
         <EditorIcon icon="Emoji" />
       </Button>,
@@ -1043,7 +1110,8 @@ class CodeMirrorEditor extends AbstractEditor {
               this.props.onDragEnter(event);
             }
           }}
-          onKeyUp={this.keyUpHandler}
+          onKeyPress={this.keyPressHandler}
+          onKeyDown={this.keyDownHandler}
         />
 
         { this.renderLoadingKeymapOverlay() }
@@ -1059,17 +1127,11 @@ class CodeMirrorEditor extends AbstractEditor {
           ref={this.linkEditModal}
           onSave={(linkText) => { return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
         />
-        {/*
-        <HandsontableModal
+        {/* <HandsontableModal
           ref={this.handsontableModal}
           onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}
           autoFormatMarkdownTable={this.props.editorSettings.autoFormatMarkdownTable}
         /> */}
-        {/* <DrawioModal
-          ref={this.drawioModal}
-          onSave={this.onSaveForDrawio}
-        /> */}
-
       </div>
     );
   }
@@ -1088,4 +1150,17 @@ CodeMirrorEditor.defaultProps = {
   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>
+  );
+};

+ 9 - 3
packages/app/src/components/PageEditor/Editor.tsx

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

+ 9 - 13
packages/app/src/components/PageEditor/EmojiPicker.tsx

@@ -1,4 +1,4 @@
-import React, { FC } from 'react';
+import React, { FC, useCallback } from 'react';
 
 import { Picker } from 'emoji-mart';
 import { Modal } from 'reactstrap';
@@ -9,6 +9,7 @@ import EmojiPickerHelper, { getEmojiTranslation } from './EmojiPickerHelper';
 
 type Props = {
   onClose: () => void,
+  onSelected: (emoji: string) => void,
   emojiSearchText: string,
   emojiPickerHelper: EmojiPickerHelper,
   isOpen: boolean
@@ -17,30 +18,25 @@ type Props = {
 const EmojiPicker: FC<Props> = (props: Props) => {
 
   const {
-    onClose, emojiSearchText, emojiPickerHelper, isOpen,
+    onClose, onSelected, emojiSearchText, emojiPickerHelper, isOpen,
   } = props;
 
   const { resolvedTheme } = useNextThemes();
 
   // Set search emoji input and trigger search
-  const searchEmoji = () => {
+  const searchEmoji = useCallback(() => {
     const input = window.document.querySelector('[id^="emoji-mart-search"]') as HTMLInputElement;
     const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
     valueSetter?.call(input, emojiSearchText);
     const event = new Event('input', { bubbles: true });
     input.dispatchEvent(event);
     input.focus();
-  };
-
-  const selectEmoji = (emoji) => {
-    if (emojiSearchText !== null) {
-      emojiPickerHelper.addEmojiOnSearch(emoji);
-    }
-    else {
-      emojiPickerHelper.addEmoji(emoji);
-    }
+  }, [emojiSearchText]);
+
+  const selectEmoji = useCallback((emoji) => {
+    onSelected(emoji);
     onClose();
-  };
+  }, [onClose, onSelected]);
 
 
   const translation = getEmojiTranslation();

+ 28 - 39
packages/app/src/components/PageEditor/EmojiPickerHelper.ts

@@ -1,22 +1,22 @@
 import { CSSProperties } from 'react';
 
+import { Position } from 'codemirror';
 import i18n from 'i18next';
 
-// https://regex101.com/r/Gqhor8/1
-const EMOJI_PATTERN = new RegExp(/\B:[^:\s]+/);
+// https://regex101.com/r/x5LbOZ/1
+const EMOJI_PATTERN = new RegExp(/^:[a-z0-9-+_]+$/);
 
 export default class EmojiPickerHelper {
 
   editor;
 
-  pattern: RegExp;
+  pattern: string;
 
   constructor(editor) {
     this.editor = editor;
-    this.pattern = EMOJI_PATTERN;
   }
 
-  setStyle = ():CSSProperties => {
+  setStyle = (): CSSProperties => {
     const offset = 20;
     const emojiPickerHeight = 420;
     const cursorPos = this.editor.cursorCoords(true);
@@ -36,53 +36,42 @@ export default class EmojiPickerHelper {
     };
   };
 
-  getSearchCursor = () => {
-    const currentPos = this.editor.getCursor();
-    const sc = this.editor.getSearchCursor(this.pattern, currentPos, { multiline: false });
-    return sc;
-  };
+  shouldModeTurnOn = (char: string): Position | null | undefined => {
+    if (char !== ':') {
+      return null;
+    }
 
-  // Add emoji when triggered by search
-  addEmojiOnSearch = (emoji) => {
     const currentPos = this.editor.getCursor();
-    const sc = this.getSearchCursor();
+    const sc = this.editor.getSearchCursor(':', currentPos, { multiline: false });
     if (sc.findPrevious()) {
-      sc.replace(`${emoji.colons} `, this.editor.getTokenAt(currentPos).string);
-      this.editor.focus();
-      this.editor.refresh();
+      return sc.pos.from;
     }
   };
 
+  shouldOpen = (startPos: Position): boolean => {
+    const currentPos = this.editor.getCursor();
+    const rangeStr = this.editor.getRange(startPos, currentPos);
+
+    return EMOJI_PATTERN.test(rangeStr);
+  };
 
-  // Add emoji when triggered by click emoji icon on top of editor
-  addEmoji = (emoji) => {
+  getInitialSearchingText = (startPos: Position): void => {
     const currentPos = this.editor.getCursor();
-    const doc = this.editor.getDoc();
-    doc.replaceRange(`${emoji.colons} `, currentPos);
-    this.editor.focus();
-    this.editor.refresh();
+    const rangeStr = this.editor.getRange(startPos, currentPos);
+
+    return rangeStr.slice(1); // return without the heading ':'
   };
 
-  getEmoji = () => {
-    const sc = this.getSearchCursor();
+  addEmoji = (emoji: { colons: string }, startPosToReplace: Position|null): void => {
     const currentPos = this.editor.getCursor();
 
-    if (sc.findPrevious()) {
-      const isInputtingEmoji = (currentPos.line === sc.to().line && currentPos.ch === sc.to().ch);
-      // current search cursor position
-      if (!isInputtingEmoji) {
-        return;
-      }
-      const pos = {
-        line: sc.to().line,
-        ch: sc.to().ch,
-      };
-      const currentSearchText = sc.matches(true, pos).match[0];
-      const searchWord = currentSearchText.replace(':', '');
-      return searchWord;
-    }
+    const from = startPosToReplace ?? currentPos;
+    const to = currentPos;
 
-    return;
+    const doc = this.editor.getDoc();
+    doc.replaceRange(`${emoji.colons} `, from, to);
+    this.editor.focus();
+    this.editor.refresh();
   };
 
 }

+ 5 - 0
packages/app/src/interfaces/errors/login-error.ts

@@ -0,0 +1,5 @@
+export const LoginErrorCode = {
+  PROVIDER_DUPLICATED_USERNAME_EXCEPTION: 'provider-duplicated-username-exception',
+} as const;
+
+export type LoginErrorCode = typeof LoginErrorCode[keyof typeof LoginErrorCode];

+ 3 - 0
packages/app/src/interfaces/errors/v3-error.ts

@@ -0,0 +1,3 @@
+import { ErrorV3 } from '@growi/core';
+
+export type IErrorV3 = ErrorV3

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

@@ -1,12 +1,5 @@
 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 = {
   isEnabledLinebreaks: boolean,

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

@@ -58,7 +58,7 @@ import {
   useIsForbidden, useIsNotFound, useIsTrashPage, useIsSharedUser,
   useIsEnabledStaleNotification, useIsIdenticalPath,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useDisableLinkSharing,
-  useHackmdUri,
+  useDrawioUri, useHackmdUri,
   useIsAclEnabled, useIsUserPage, useIsSearchPage,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
   useIsSlackConfigured, useRendererConfig, useEditingMarkdown,
@@ -147,9 +147,9 @@ type Props = CommonProps & {
   // isMailerSetup: boolean,
   isAclEnabled: boolean,
   // hasSlackConfig: boolean,
-  // drawioUri: string,
+  drawioUri: string,
   hackmdUri: string,
-  // noCdn: string,
+  noCdn: string,
   // highlightJsStyle: string,
   isAllReplyShown: boolean,
   // isContainerFluid: boolean,
@@ -214,12 +214,11 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   // useIsMailerSetup(props.isMailerSetup);
   useIsAclEnabled(props.isAclEnabled);
   // useHasSlackConfig(props.hasSlackConfig);
-  // useDrawioUri(props.drawioUri);
+  useDrawioUri(props.drawioUri);
   useHackmdUri(props.hackmdUri);
   // useNoCdn(props.noCdn);
   // useIndentSize(props.adminPreferredIndentSize);
   useDisableLinkSharing(props.disableLinkSharing);
-
   useRendererConfig(props.rendererConfig);
   // useRendererSettings(props.rendererSettingsStr != null ? JSON.parse(props.rendererSettingsStr) : undefined);
   // useGrowiRendererConfig(props.growiRendererConfigStr != null ? JSON.parse(props.growiRendererConfigStr) : undefined);
@@ -492,9 +491,9 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   // props.isMailerSetup = mailService.isMailerSetup;
   props.isAclEnabled = aclService.isAclEnabled();
   // 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.noCdn = configManager.getConfig('crowi', 'app:noCdn');
+  props.noCdn = configManager.getConfig('crowi', 'app:noCdn');
   // props.highlightJsStyle = configManager.getConfig('crowi', 'customize:highlightJsStyle');
   props.isAllReplyShown = configManager.getConfig('crowi', 'customize:isAllReplyShown');
   // props.isContainerFluid = configManager.getConfig('crowi', 'customize:isContainerFluid');

+ 10 - 2
packages/app/src/pages/login.page.tsx

@@ -26,6 +26,9 @@ type Props = CommonProps & {
   isMailerSetup: boolean,
   enabledStrategies: unknown,
   registrationWhiteList: string[],
+  isLocalStrategySetup: boolean,
+  isLdapStrategySetup: boolean,
+  isLdapSetupFailed: boolean,
 };
 
 const LoginPage: NextPage<Props> = (props: Props) => {
@@ -43,8 +46,9 @@ const LoginPage: NextPage<Props> = (props: Props) => {
       <LoginForm
         // Todo: These props should be set properly. https://redmine.weseek.co.jp/issues/104847
         objOfIsExternalAuthEnableds={props.enabledStrategies}
-        isLocalStrategySetup={true}
-        isLdapStrategySetup={true}
+        isLocalStrategySetup={props.isLocalStrategySetup}
+        isLdapStrategySetup={props.isLdapStrategySetup}
+        isLdapSetupFailed={props.isLdapSetupFailed}
         isEmailAuthenticationEnabled={false}
         isRegistrationEnabled={true}
         registrationWhiteList={props.registrationWhiteList}
@@ -92,9 +96,13 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
   const {
     mailService,
     configManager,
+    passportService,
   } = crowi;
 
   props.isMailerSetup = mailService.isMailerSetup;
+  props.isLocalStrategySetup = passportService.isLocalStrategySetup;
+  props.isLdapStrategySetup = passportService.isLdapStrategySetup;
+  props.isLdapSetupFailed = configManager.getConfig('crowi', 'security:passport-ldap:isEnabled') && !props.isLdapStrategySetup;
   props.registrationWhiteList = configManager.getConfig('crowi', 'security:registrationWhiteList');
 }
 

+ 39 - 5
packages/app/src/pages/share/[[...path]].page.tsx

@@ -10,6 +10,7 @@ import dynamic from 'next/dynamic';
 import { ShareLinkLayout } from '~/components/Layout/ShareLinkLayout';
 import GrowiContextualSubNavigation from '~/components/Navbar/GrowiContextualSubNavigation';
 import { Page } from '~/components/Page';
+import { SupportedAction, SupportedActionType } from '~/interfaces/activity';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { RendererConfig } from '~/interfaces/services/renderer';
 import { IShareLinkHasId } from '~/interfaces/share-link';
@@ -17,11 +18,14 @@ import {
   useCurrentUser, useCurrentPagePath, useCurrentPathname, useCurrentPageId, useRendererConfig,
   useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault,
 } from '~/stores/context';
+import loggerFactory from '~/utils/logger';
 
 import {
   CommonProps, getServerSideCommonProps, useCustomTitle, getNextI18NextConfig,
 } from '../utils/commons';
 
+const logger = loggerFactory('growi:next-page:share');
+
 const ShareLinkAlert = dynamic(() => import('~/components/Page/ShareLinkAlert'), { ssr: false });
 const ForbiddenPage = dynamic(() => import('~/components/ForbiddenPage'), { ssr: false });
 
@@ -130,9 +134,40 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
   props._nextI18Next = nextI18NextConfig._nextI18Next;
 }
 
+function getAction(props: Props): SupportedActionType {
+  let action: SupportedActionType;
+  if (props.isExpired) {
+    action = SupportedAction.ACTION_SHARE_LINK_EXPIRED_PAGE_VIEW;
+  }
+  else if (props.shareLink == null) {
+    action = SupportedAction.ACTION_SHARE_LINK_NOT_FOUND;
+  }
+  else {
+    action = SupportedAction.ACTION_SHARE_LINK_PAGE_VIEW;
+  }
+
+  return action;
+}
+
+async function addActivity(context: GetServerSidePropsContext, action: SupportedActionType): Promise<void> {
+  const req: CrowiRequest = context.req as CrowiRequest;
+
+  const parameters = {
+    ip: req.ip,
+    endpoint: req.originalUrl,
+    action,
+    user: req.user?._id,
+    snapshot: {
+      username: req.user?.username,
+    },
+  };
+
+  await req.crowi.activityService.createActivity(parameters);
+}
+
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
   const req = context.req as CrowiRequest<IUserHasId & any>;
-  const { user, crowi } = req;
+  const { user, crowi, params } = req;
   const result = await getServerSideCommonProps(context);
 
   if (!('props' in result)) {
@@ -144,22 +179,21 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
     props.currentUser = user.toObject();
   }
 
-  const { linkId } = req.params;
   try {
     const ShareLinkModel = crowi.model('ShareLink');
-    const shareLink = await ShareLinkModel.findOne({ _id: linkId }).populate('relatedPage');
+    const shareLink = await ShareLinkModel.findOne({ _id: params.linkId }).populate('relatedPage');
     if (shareLink != null) {
       props.isExpired = shareLink.isExpired();
       props.shareLink = shareLink.toObject();
     }
   }
   catch (err) {
-    //
+    logger.error(err);
   }
 
   injectServerConfigurations(context, props);
-  // await injectUserUISettings(context, props);
   await injectNextI18NextConfigurations(context, props);
+  await addActivity(context, getAction(props));
 
   return {
     props,

+ 1 - 1
packages/app/src/pages/utils/commons.ts

@@ -4,10 +4,10 @@ import { SSRConfig, UserConfig } from 'next-i18next';
 
 import * as nextI18NextConfig from '^/config/next-i18next.config';
 
+import { SupportedActionType } from '~/interfaces/activity';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { GrowiThemes } from '~/interfaces/theme';
 
-
 export type CommonProps = {
   namespacesRequired: string[], // i18next
   currentPathname: string,

+ 2 - 2
packages/app/src/server/middlewares/apiv3-form-validator.ts

@@ -1,11 +1,11 @@
+import { ErrorV3 } from '@growi/core';
 import { NextFunction, Request, Response } from 'express';
+
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:middlewares:ApiV3FormValidator');
 const { validationResult } = require('express-validator');
 
-const ErrorV3 = require('../models/vo/error-apiv3');
-
 export const apiV3FormValidator = (req: Request, res: Response & { apiv3Err }, next: NextFunction): void => {
   logger.debug('req.query', req.query);
   logger.debug('req.params', req.params);

+ 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';
 
-// 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
 export const loginRules = () => {
   return [

+ 7 - 12
packages/app/src/server/models/vo/error-apiv3.js → packages/app/src/server/models/vo/error-v3.js

@@ -1,3 +1,6 @@
+// The jsdoc below is left here intentionally to be referenced by swagger's $ref in a file under src/server/routes/apiv3/**/*.js such as healthcheck.js
+// The actual ErrorV3 Class is moved to packages/core/models/vo/error-v3.js
+
 /**
  * @swagger
  *
@@ -15,17 +18,9 @@
  *            example: 'someapi-error-with-something'
  *          stack:
  *            type: object
+ *          args:
+ *            type: object
+ *            example: { name: 'Josh', age: 20 }
  */
 
-class ErrorV3 extends Error {
-
-  constructor(message = '', code = '', stack = undefined) {
-    super(); // do not provide message to the super constructor
-    this.message = message;
-    this.code = code;
-    this.stack = stack;
-  }
-
-}
-
-module.exports = ErrorV3;
+export {};

+ 2 - 3
packages/app/src/server/routes/apiv3/app-settings.js

@@ -1,3 +1,4 @@
+import { ErrorV3 } from '@growi/core';
 import { body } from 'express-validator';
 
 import { i18n } from '^/config/next-i18next.config';
@@ -11,14 +12,12 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:app-settings');
 
+const { pathUtils } = require('@growi/core');
 const debug = require('debug')('growi:routes:admin');
 const express = require('express');
-const { pathUtils } = require('@growi/core');
 
 const router = express.Router();
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 /**
  * @swagger
  *  tags:

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

@@ -1,16 +1,16 @@
+import { ErrorV3 } from '@growi/core';
+
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:attachment'); // eslint-disable-line no-unused-vars
-
 const express = require('express');
 
 const router = express.Router();
 const { query } = require('express-validator');
 
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
-const ErrorV3 = require('../../models/vo/error-apiv3');
 
 /**
  * @swagger

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

@@ -1,5 +1,7 @@
 /* eslint-disable no-unused-vars */
 
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import { AttachmentType } from '~/server/interfaces/attachment';
 import loggerFactory from '~/utils/logger';
@@ -17,8 +19,6 @@ const router = express.Router();
 const { body, query } = require('express-validator');
 const multer = require('multer');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 
 /**
  * @swagger

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

@@ -1,10 +1,10 @@
+import { ErrorV3 } from '@growi/core';
 import { format, subSeconds } from 'date-fns';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import injectResetOrderByTokenMiddleware from '~/server/middlewares/inject-reset-order-by-token-middleware';
 import PasswordResetOrder from '~/server/models/password-reset-order';
-import ErrorV3 from '~/server/models/vo/error-apiv3';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';

+ 2 - 1
packages/app/src/server/routes/apiv3/healthcheck.js

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:routes:apiv3:healthcheck'); // eslint-disable-line no-unused-vars
@@ -7,7 +9,6 @@ const express = require('express');
 const router = express.Router();
 
 const noCache = require('nocache');
-const ErrorV3 = require('../../models/vo/error-apiv3');
 
 /**
  * @swagger

+ 1 - 2
packages/app/src/server/routes/apiv3/import.js

@@ -1,3 +1,4 @@
+import { ErrorV3 } from '@growi/core';
 import mongoose from 'mongoose';
 
 import { SupportedAction } from '~/interfaces/activity';
@@ -16,8 +17,6 @@ const multer = require('multer');
 
 const GrowiArchiveImportOption = require('~/models/admin/growi-archive-import-option');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 
 const router = express.Router();
 

+ 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,
     addActivity, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.cannotLoginErrorHadnler, loginPassport.loginFailure);
 
+  routerForAuth.use('/invited', require('./invited')(crowi));
   routerForAuth.use('/logout', require('./logout')(crowi));
 
   routerForAuth.post('/register',
     applicationInstalled, registerFormValidator.registerRules(), registerFormValidator.registerValidation, addActivity, login.register);
 
+
   // installer
   if (!isInstalled) {
     routerForAdmin.use('/installer', require('./installer')(crowi));

+ 1 - 1
packages/app/src/server/routes/apiv3/installer.ts

@@ -1,7 +1,7 @@
+import { ErrorV3 } from '@growi/core';
 import express, { Request, Router } from 'express';
 
 import { SupportedAction } from '~/interfaces/activity';
-import ErrorV3 from '~/server/models/vo/error-apiv3';
 import loggerFactory from '~/utils/logger';
 
 import Crowi from '../../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;
+};

+ 2 - 2
packages/app/src/server/routes/apiv3/markdown-setting.js

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
@@ -13,8 +15,6 @@ const router = express.Router();
 
 const { body } = require('express-validator');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 const validator = {
   lineBreak: [
     body('isEnabledLinebreaks').isBoolean(),

+ 2 - 2
packages/app/src/server/routes/apiv3/notification-setting.js

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
@@ -16,8 +18,6 @@ const router = express.Router();
 
 const { body } = require('express-validator');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 const validator = {
   userNotification: [
     body('pathPattern').isString().trim(),

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

@@ -1,3 +1,4 @@
+import { ErrorV3 } from '@growi/core';
 import express, { Request, Router } from 'express';
 import { query, oneOf } from 'express-validator';
 import mongoose from 'mongoose';
@@ -9,7 +10,6 @@ import loggerFactory from '~/utils/logger';
 import Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { PageModel } from '../../models/page';
-import ErrorV3 from '../../models/vo/error-apiv3';
 import PageService from '../../service/page';
 
 import { ApiV3Response } from './interfaces/apiv3-response';

+ 3 - 2
packages/app/src/server/routes/apiv3/page.js

@@ -1,4 +1,6 @@
-import { pagePathUtils, AllSubscriptionStatusType, SubscriptionStatusType } from '@growi/core';
+import {
+  pagePathUtils, AllSubscriptionStatusType, SubscriptionStatusType, ErrorV3,
+} from '@growi/core';
 
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
@@ -15,7 +17,6 @@ const { body, query, param } = require('express-validator');
 
 const router = express.Router();
 const { convertToNewAffiliationPath, isTopPage } = pagePathUtils;
-const ErrorV3 = require('../../models/vo/error-apiv3');
 
 /**
  * @swagger

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

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 import loggerFactory from '~/utils/logger';
@@ -13,8 +15,6 @@ const { body } = require('express-validator');
 const { query } = require('express-validator');
 const mongoose = require('mongoose');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 const { isCreatablePage } = pagePathUtils;
 
 const router = express.Router();

+ 2 - 1
packages/app/src/server/routes/apiv3/response.js

@@ -1,6 +1,7 @@
+import { ErrorV3 } from '@growi/core';
+
 import { toArrayIfNot } from '~/utils/array-utils';
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
 
 const addCustomFunctionToResponse = (express, crowi) => {
 

+ 3 - 2
packages/app/src/server/routes/apiv3/revisions.js

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
@@ -5,10 +7,9 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 const logger = loggerFactory('growi:routes:apiv3:pages');
 
 const express = require('express');
-
 const { query, param } = require('express-validator');
+
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
-const ErrorV3 = require('../../models/vo/error-apiv3');
 
 const router = express.Router();
 

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

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
@@ -14,8 +16,6 @@ const router = express.Router();
 
 const noCache = require('nocache');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 /**
  * @swagger
  *  tags:

+ 2 - 2
packages/app/src/server/routes/apiv3/security-setting.js

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import { PageDeleteConfigValue } from '~/interfaces/page-delete-config';
 import loggerFactory from '~/utils/logger';
@@ -16,8 +18,6 @@ const router = express.Router();
 
 const { body } = require('express-validator');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 const validator = {
   generalSetting: [
     body('sessionMaxAge').optional({ checkFalsy: true }).trim().isInt(),

+ 2 - 2
packages/app/src/server/routes/apiv3/share-links.js

@@ -1,5 +1,7 @@
 // TODO remove this setting after implemented all
 /* eslint-disable no-unused-vars */
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import loggerFactory from '~/utils/logger';
@@ -15,8 +17,6 @@ const router = express.Router();
 
 const { body, query, param } = require('express-validator');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 const validator = {};
 
 const today = new Date();

+ 2 - 2
packages/app/src/server/routes/apiv3/slack-integration-legacy-settings.js

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
@@ -14,8 +16,6 @@ const router = express.Router();
 
 const { body } = require('express-validator');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 const validator = {
   slackConfiguration: [
     body('webhookUrl').if(value => value != null).isString().trim(),

+ 1 - 2
packages/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -1,3 +1,4 @@
+import { ErrorV3 } from '@growi/core';
 
 import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
@@ -20,8 +21,6 @@ const { body, query, param } = require('express-validator');
 const mongoose = require('mongoose');
 const urljoin = require('url-join');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
-
 const logger = loggerFactory('growi:routes:apiv3:slack-integration-settings');
 
 const router = express.Router();

+ 1 - 2
packages/app/src/server/routes/apiv3/slack-integration.js

@@ -1,10 +1,9 @@
+import { ErrorV3 } from '@growi/core';
 import createError from 'http-errors';
 
 import { SlackCommandHandlerError } from '~/server/models/vo/slack-command-handler-error';
 import loggerFactory from '~/utils/logger';
 
-import ErrorV3 from '../../models/vo/error-apiv3';
-
 import {
   markdownSectionBlock, InvalidGrowiCommandError, generateRespondUtil, supportedGrowiCommands,
 } from '@growi/slack';

+ 2 - 1
packages/app/src/server/routes/apiv3/user-activation.ts

@@ -1,7 +1,8 @@
 import path from 'path';
+
+import { ErrorV3 } from '@growi/core';
 import * as express from 'express';
 import { body, validationResult } from 'express-validator';
-import ErrorV3 from '../../models/vo/error-apiv3';
 
 const PASSOWRD_MINIMUM_NUMBER = 8;
 // validation rules for complete registration form

+ 2 - 1
packages/app/src/server/routes/apiv3/user-group-relation.js

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:routes:apiv3:user-group-relation'); // eslint-disable-line no-unused-vars
@@ -5,7 +7,6 @@ const logger = loggerFactory('growi:routes:apiv3:user-group-relation'); // eslin
 const express = require('express');
 const { query } = require('express-validator');
 
-const ErrorV3 = require('../../models/vo/error-apiv3');
 const { serializeUserGroupRelationSecurely } = require('../../models/serializers/user-group-relation-serializer');
 
 const router = express.Router();

+ 2 - 1
packages/app/src/server/routes/apiv3/user-group.js

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import UserGroup from '~/server/models/user-group';
 import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
@@ -18,7 +20,6 @@ const { sanitizeQuery } = require('express-validator');
 const mongoose = require('mongoose');
 
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
-const ErrorV3 = require('../../models/vo/error-apiv3');
 const { toPagingLimit, toPagingOffset } = require('../../util/express-validator/sanitizer');
 
 const { ObjectId } = mongoose.Types;

+ 2 - 3
packages/app/src/server/routes/apiv3/user-ui-settings.ts

@@ -1,13 +1,12 @@
+import { ErrorV3 } from '@growi/core';
 import express from 'express';
 import { body } from 'express-validator';
-import { AllSidebarContentsType } from '~/interfaces/ui';
 
+import { AllSidebarContentsType } from '~/interfaces/ui';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-
 import UserUISettings from '../../models/user-ui-settings';
-import ErrorV3 from '../../models/vo/error-apiv3';
 
 const logger = loggerFactory('growi:routes:apiv3:user-ui-settings');
 

+ 2 - 1
packages/app/src/server/routes/apiv3/users.js

@@ -1,3 +1,5 @@
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
 import Activity from '~/server/models/activity';
 import loggerFactory from '~/utils/logger';
@@ -19,7 +21,6 @@ const { isEmail } = require('validator');
 
 const { serializePageSecurely } = require('../../models/serializers/page-serializer');
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
-const ErrorV3 = require('../../models/vo/error-apiv3');
 
 const PAGE_ITEMS = 50;
 

+ 1 - 1
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'                    , applicationInstalled, login.preLogin, 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);
 

+ 23 - 11
packages/app/src/server/routes/login-passport.js

@@ -1,8 +1,11 @@
+
+import { ErrorV3 } from '@growi/core';
+
 import { SupportedAction } from '~/interfaces/activity';
+import { LoginErrorCode } from '~/interfaces/errors/login-error';
 import { NullUsernameToBeRegisteredError } from '~/server/models/errors';
 import loggerFactory from '~/utils/logger';
 
-import ErrorV3 from '../models/vo/error-apiv3';
 
 /* eslint-disable no-use-before-define */
 
@@ -11,6 +14,7 @@ module.exports = function(crowi, app) {
   const logger = loggerFactory('growi:routes:login-passport');
   const passport = require('passport');
   const ExternalAccount = crowi.model('ExternalAccount');
+  const User = crowi.model('User');
   const passportService = crowi.passportService;
 
   const activityEvent = crowi.event('activity');
@@ -66,7 +70,7 @@ module.exports = function(crowi, app) {
       /* eslint-disable no-else-return */
       if (err instanceof NullUsernameToBeRegisteredError) {
         logger.error(err.message);
-        throw Error(err.message);
+        throw new ErrorV3(err.message);
       }
       else if (err.name === 'DuplicatedUsernameException') {
         if (isSameEmailTreatedAsIdenticalUser || isSameUsernameTreatedAsIdenticalUser) {
@@ -75,11 +79,13 @@ module.exports = function(crowi, app) {
           return ExternalAccount.associate(providerId, userInfo.id, err.user);
         }
         logger.error('provider-DuplicatedUsernameException', providerId);
-        throw Error(`provider-DuplicatedUsernameException: ${providerId}`);
+
+        throw new ErrorV3('message.provider_duplicated_username_exception', LoginErrorCode.PROVIDER_DUPLICATED_USERNAME_EXCEPTION,
+          undefined, { failedProviderForDuplicatedUsernameException: providerId });
       }
       else if (err.name === 'UserUpperLimitException') {
         logger.error(err.message);
-        throw Error(err.message);
+        throw new ErrorV3(err.message);
       }
       /* eslint-enable no-else-return */
     }
@@ -91,6 +97,7 @@ module.exports = function(crowi, app) {
    * @param {*} res
    */
   const loginSuccessHandler = async(req, res, user, action) => {
+
     // update lastLoginAt
     user.updateLastLoginAt(new Date(), (err, userData) => {
       if (err) {
@@ -99,7 +106,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
     delete req.session.redirectTo;
 
@@ -112,6 +121,7 @@ module.exports = function(crowi, app) {
         username: req.user.username,
       },
     };
+
     await crowi.activityService.createActivity(parameters);
 
     return res.apiv3({ redirectTo });
@@ -133,21 +143,23 @@ module.exports = function(crowi, app) {
 
   const cannotLoginErrorHadnler = (req, res, next) => {
     // this is called when all login method is somehow failed without invoking 'return next(<any Error>)'
-    const err = res.locals.err != null ? res.locals.err : Error('message.sign_in_failure');
+    const err = new ErrorV3('message.sign_in_failure');
     return next(err);
   };
 
   /**
    * middleware for login failure
+   * @param {*} error
    * @param {*} req
    * @param {*} res
+   * @param {*} next
    */
   const loginFailure = (error, req, res, next) => {
 
     const parameters = { action: SupportedAction.ACTION_USER_LOGIN_FAILURE };
     activityEvent.emit('update', res.locals.activity._id, parameters);
 
-    return res.apiv3Err(error, error.code);
+    return res.apiv3Err(error);
   };
 
   /**
@@ -175,7 +187,7 @@ module.exports = function(crowi, app) {
   const loginWithLdap = async(req, res, next) => {
     if (!passportService.isLdapStrategySetup) {
       debug('LdapStrategy has not been set up');
-      return res.apiv3Err('message.strategy_has_not_been_set_up.LdapStrategy', 405);
+      return next();
     }
 
     if (!req.form.isValid) {
@@ -196,7 +208,7 @@ module.exports = function(crowi, app) {
 
     // check groups for LDAP
     if (!isValidLdapUserByGroupFilter(ldapAccountInfo)) {
-      return next(ErrorV3('message.ldap_user_not_valid', 400));
+      return next(new ErrorV3('message.ldap_user_not_valid'));
     }
 
     /*
@@ -229,7 +241,7 @@ module.exports = function(crowi, app) {
 
     // just in case the returned value is null or undefined
     if (externalAccount == null) {
-      return next(Error('message.external_account_not_exist'));
+      return next(new ErrorV3('message.external_account_not_exist'));
     }
 
     const user = await externalAccount.getPopulatedUser();
@@ -310,7 +322,7 @@ module.exports = function(crowi, app) {
   const loginWithLocal = (req, res, next) => {
     if (!passportService.isLocalStrategySetup) {
       debug('LocalStrategy has not been set up');
-      return res.apiv3Err('message.strategy_has_not_been_set_up.LocalStrategy', 405);
+      return res.apiv3Err(new ErrorV3('message.strategy_has_not_been_set_up', '', undefined, { strategy: 'LocalStrategy' }), 405);
     }
 
     if (!req.form.isValid) {

+ 0 - 45
packages/app/src/server/routes/login.js

@@ -169,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;
 };

+ 4 - 0
packages/app/src/stores/context.tsx

@@ -130,6 +130,10 @@ export const useRevisionIdHackmdSynced = (initialData?: Nullable<any>): SWRRespo
   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> => {
   return useStaticSWR<Nullable<string>, Error>('hackmdUri', initialData);
 };

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

@@ -5,6 +5,7 @@ import {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
 } from '~/interfaces/ui';
 import { IUserGroupHasId } from '~/interfaces/user';
+import { dwawioConfig } from '~/utils/drawio-config';
 
 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,
+  };
+};

+ 2 - 1
packages/app/src/stores/ui.tsx

@@ -414,10 +414,11 @@ export const useIsAbleToShowPageManagement = (): SWRResponse<boolean, Error> =>
   const pageId = currentPageId;
   const includesUndefined = [pageId, isTrashPage, isSharedUser, isNotFound].some(v => v === undefined);
   const isPageExist = (pageId != null) && !isNotFound;
+  const isEmptyPage = (pageId != null) && isNotFound;
 
   return useSWRImmutable(
     includesUndefined ? null : [key, pageId],
-    () => isPageExist && !isTrashPage && !isSharedUser,
+    () => (isPageExist && !isTrashPage && !isSharedUser) || (isEmptyPage != null && isEmptyPage),
   );
 };
 

+ 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 - 0
packages/core/src/index.ts

@@ -24,6 +24,7 @@ export * from './interfaces/tag';
 export * from './interfaces/user';
 export * from './plugin/service/tag-cache-manager';
 export * from './models/devided-page-path';
+export * from './models/vo/error-apiv3';
 export * from './service/localstorage-manager';
 export * from './utils/basic-interceptor';
 export * from './utils/browser-utils';

+ 15 - 0
packages/core/src/models/vo/error-apiv3.ts

@@ -0,0 +1,15 @@
+export class ErrorV3 extends Error {
+
+  code: string;
+
+  args?: any;
+
+  constructor(message = '', code = '', stack = undefined, args = undefined) {
+    super(); // do not provide message to the super constructor
+    this.message = message;
+    this.code = code;
+    this.stack = stack;
+    this.args = args;
+  }
+
+}