Ver código fonte

Merge branch 'master' into fix/83696-83702-fix-search-result-item

yohei0125 4 anos atrás
pai
commit
056f7cc7de
35 arquivos alterados com 940 adições e 210 exclusões
  1. 1 1
      packages/app/package.json
  2. 10 0
      packages/app/resource/locales/en_US/notifications/userActivation.txt
  3. 11 3
      packages/app/resource/locales/en_US/translation.json
  4. 11 0
      packages/app/resource/locales/ja_JP/notifications/userActivation.txt
  5. 10 2
      packages/app/resource/locales/ja_JP/translation.json
  6. 10 0
      packages/app/resource/locales/zh_CN/notifications/userActivation.txt
  7. 11 3
      packages/app/resource/locales/zh_CN/translation.json
  8. 25 0
      packages/app/src/client/nologin.jsx
  9. 12 1
      packages/app/src/client/services/AdminLocalSecurityContainer.js
  10. 0 1
      packages/app/src/client/services/EditorContainer.js
  11. 1 1
      packages/app/src/components/Admin/App/AppSettingsPageContents.jsx
  12. 46 2
      packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  13. 148 0
      packages/app/src/components/CompleteUserRegistrationForm.tsx
  14. 59 27
      packages/app/src/components/LoginForm.jsx
  15. 1 1
      packages/app/src/components/PageComment/CommentEditor.jsx
  16. 13 14
      packages/app/src/components/PageEditor/EditorNavbarBottom.tsx
  17. 0 87
      packages/app/src/components/SlackNotification.jsx
  18. 67 0
      packages/app/src/components/SlackNotification.tsx
  19. 22 0
      packages/app/src/server/middlewares/inject-user-registration-order-by-token-middleware.ts
  20. 67 0
      packages/app/src/server/models/user-registration-order.ts
  21. 10 0
      packages/app/src/server/models/user.js
  22. 12 0
      packages/app/src/server/routes/apiv3/index.js
  23. 3 0
      packages/app/src/server/routes/apiv3/security-setting.js
  24. 138 0
      packages/app/src/server/routes/apiv3/user-activation.ts
  25. 6 0
      packages/app/src/server/routes/index.js
  26. 114 0
      packages/app/src/server/routes/user-activation.ts
  27. 9 6
      packages/app/src/server/routes/user.js
  28. 6 0
      packages/app/src/server/service/config-loader.ts
  29. 3 2
      packages/app/src/server/views/login.html
  30. 52 0
      packages/app/src/server/views/user-activation.html
  31. 9 0
      packages/app/src/stores/editor.tsx
  32. 1 0
      packages/app/src/styles/_layout.scss
  33. 2 0
      packages/app/src/styles/_sidebar.scss
  34. 4 1
      packages/app/src/styles/atoms/_buttons.scss
  35. 46 58
      yarn.lock

+ 1 - 1
packages/app/package.json

@@ -72,7 +72,7 @@
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
     "async-canvas-to-blob": "^1.0.3",
-    "aws-sdk": "^2.88.0",
+    "aws-sdk": "^2.1044.0",
     "axios": "^0.24.0",
     "body-parser": "^1.18.2",
     "browser-bunyan": "^1.6.3",

+ 10 - 0
packages/app/resource/locales/en_US/notifications/userActivation.txt

@@ -0,0 +1,10 @@
+Account confirmation
+
+Hi, {{ email }}
+
+An acount has been created in GROWI {{ appTitle }}.
+To activate your account, click on the link below.
+
+{{ url }}
+
+If you did not created the account, you can safely ignore this email.

+ 11 - 3
packages/app/resource/locales/en_US/translation.json

@@ -189,6 +189,7 @@
     "v346_using_basic_auth": "Basic Authentication currently in use will <strong>no longer be available</strong> in the near future. Remove settings from %s"
   },
   "page_register": {
+    "send_email": "Send email",
     "notice": {
       "restricted": "Admin approval required.",
       "restricted_defail": "Once the admin approves your sign up, you'll be able to access this wiki."
@@ -665,7 +666,12 @@
       "enable_local": "Enable ID/Password",
       "password_reset_by_users": "Password reset by users",
       "enable_password_reset_by_users": "Enable password reset by users",
-      "password_reset_desc": "when forgot password, users are able to reset it by themselves."
+      "password_reset_desc": "when forgot password, users are able to reset it by themselves.",
+      "email_authentication": "Email authentication on user registration",
+      "enable_email_authentication": "Enable email authentication",
+      "enable_email_authentication_desc": "Email authentication is going to be performed for user registration.",
+      "please_enable_mailer": "Please setup mailer first.",
+      "need_complete_mail_setting_warning": "To use the following functions, please complete the mail settings."
     },
     "ldap": {
       "enable_ldap": "Enable LDAP",
@@ -883,7 +889,7 @@
     "aws_sttings_required": "AWS settings required to use this function. Please ask the administrator.",
     "application_already_installed": "Application already installed.",
     "email_address_could_not_be_used": "This email address could not be used. (Make sure the allowed email address)",
-    "user_id_is_not_available.":"This User ID is not available.",
+    "user_id_is_not_available":"This User ID is not available.",
     "email_address_is_already_registered":"This email address is already registered.",
     "can_not_register_maximum_number_of_users":"Can not register more than the maximum number of users.",
     "failed_to_register":"Failed to register.",
@@ -893,7 +899,9 @@
     "unable_to_use_this_user":"Unable to use this user.",
     "complete_to_install1":"Complete to Install GROWI ! Please login as admin account.",
     "complete_to_install2":"Complete to Install GROWI ! Please check each settings on this page first.",
-    "failed_to_create_admin_user":"Failed to create admin user. {{errMessage}}"
+    "failed_to_create_admin_user":"Failed to create admin user. {{errMessage}}",
+    "successfully_send_email_auth":"We sent an email to {{email}}. Please click the URL in the email and complete the registration.",
+    "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired."
   },
   "grid_edit":{
     "create_bootstrap_4_grid":"Create Bootstrap 4 Grid",

+ 11 - 0
packages/app/resource/locales/ja_JP/notifications/userActivation.txt

@@ -0,0 +1,11 @@
+仮登録完了のお知らせ
+
+{{ email }} さん
+
+GROWI {{ appTitle }} で仮登録が完了いたしました。
+
+ご本人様確認のため、下記リンクをクリックし、アカウントの本登録を完了させて下さい。
+
+{{ url }}
+
+※当メールの内容に心当たりがない場合は、このメールを無視してください。

+ 10 - 2
packages/app/resource/locales/ja_JP/translation.json

@@ -191,6 +191,7 @@
     "v346_using_basic_auth": "現在利用中の Basic 認証機能は、近い将来<strong>廃止されます</strong>。%s から設定を削除してください。"
   },
   "page_register": {
+    "send_email": "メールを送る",
     "notice": {
       "restricted": "この Wiki への新規登録は制限されています。",
       "restricted_defail": "利用を開始するには、新規登録後、管理者による承認が必要です。"
@@ -662,7 +663,12 @@
       "enable_local": "ID/Password を有効にする",
       "password_reset_by_users": "ユーザーによるパスワード再設定",
       "enable_password_reset_by_users": "ユーザーによるパスワード再設定を有効にする",
-      "password_reset_desc": "ログイン時のパスワードを忘れた際に、ユーザー自身がパスワードを再設定できます。"
+      "password_reset_desc": "ログイン時のパスワードを忘れた際に、ユーザー自身がパスワードを再設定できます。",
+      "email_authentication": "ユーザー登録時のメール認証",
+      "enable_email_authentication": "メール認証を有効にする",
+      "enable_email_authentication_desc": "ユーザー登録時にメール認証を行います。",
+      "please_enable_mailer": "メール認証を有効にするには、メール設定を完了させてください。",
+      "need_complete_mail_setting_warning": "以下の機能を使えるようにするには、メール設定を完了させてください。"
     },
     "ldap": {
       "enable_ldap": "LDAP を有効にする",
@@ -886,7 +892,9 @@
     "unable_to_use_this_user":"利用できないユーザーIDです。",
     "complete_to_install1":"GROWI のインストールが完了しました!管理者アカウントでログインしてください。",
     "complete_to_install2":"GROWI のインストールが完了しました!はじめに、このページで各種設定を確認してください。",
-    "failed_to_create_admin_user":"管理ユーザーの作成に失敗しました。{{errMessage}}"
+    "failed_to_create_admin_user":"管理ユーザーの作成に失敗しました。{{errMessage}}",
+    "successfully_send_email_auth":"{{email}} にメールを送信しました。添付されたURLをクリックし、本登録を完了させてください",
+    "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。"
   },
   "grid_edit":{
     "create_bootstrap_4_grid":"Bootstrap 4 グリッドを作成",

+ 10 - 0
packages/app/resource/locales/zh_CN/notifications/userActivation.txt

@@ -0,0 +1,10 @@
+确认账户创建
+
+致{{ email }},
+
+已使用 GROWI {{ appTitle }} 创建帐户。
+单击下面的链接以激活您的帐户。
+
+{{ url }}
+
+如果您尚未创建,请忽略此电子邮件。

+ 11 - 3
packages/app/resource/locales/zh_CN/translation.json

@@ -189,6 +189,7 @@
 		"v346_using_basic_auth": "当前使用的基本身份验证在不久的将来将不再可用。从%s中删除设置"
 	},
 	"page_register": {
+    "send_email": "发电子邮件",
 		"notice": {
 			"restricted": "需要管理员批准。",
 			"restricted_defail": "一旦管理员批准您的注册,您就可以访问此wiki。"
@@ -640,7 +641,12 @@
       "enable_local": "Enable ID/Password",
       "password_reset_by_users": "用户重置密码",
       "enable_password_reset_by_users": "启用用户重置密码",
-      "password_reset_desc": "忘记密码时,用户可以自行重置"
+      "password_reset_desc": "忘记密码时,用户可以自行重置",
+      "email_authentication": "用户注册时的电子邮件身份验证",
+      "enable_email_authentication": "启用电子邮件身份验证",
+      "enable_email_authentication_desc": "用户注册将执行电子邮件身份验证。",
+      "please_enable_mailer": "请先设置邮件程序。",
+      "need_complete_mail_setting_warning": "要使用以下功能,请完成邮件设置。"
 		},
 		"ldap": {
 			"enable_ldap": "Enable LDAP",
@@ -886,7 +892,7 @@
 		"aws_sttings_required": "使用此功能所需的AWS设置。请询问管理员。",
 		"application_already_installed": "应用程序已安装。",
 		"email_address_could_not_be_used": "无法使用此电子邮件地址。(确保允许的电子邮件地址)",
-		"user_id_is_not_available.": "此用户ID不可用。",
+		"user_id_is_not_available": "此用户ID不可用。",
 		"email_address_is_already_registered": "此电子邮件地址已注册。",
 		"can_not_register_maximum_number_of_users": "注册的用户数不能超过最大值。",
 		"failed_to_register": "注册失败。",
@@ -896,7 +902,9 @@
 		"unable_to_use_this_user": "无法使用此用户。",
 		"complete_to_install1": "完成安装GROWI!请以管理员帐户登录。",
 		"complete_to_install2": "完成安装GROWI!请先检查此页上的每个设置。",
-		"failed_to_create_admin_user": "无法创建管理用户。{{errMessage}"
+		"failed_to_create_admin_user": "无法创建管理用户。{{errMessage}",
+    "successfully_send_email_auth":"我们向 {{email}} 发送了一封电子邮件。 请点击邮件中的网址并完成注册。",
+    "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。"
 	},
   "grid_edit":{
     "create_bootstrap_4_grid":"创建Bootstrap 4网格",

+ 25 - 0
packages/app/src/client/nologin.jsx

@@ -11,6 +11,7 @@ import InstallerForm from '../components/InstallerForm';
 import LoginForm from '../components/LoginForm';
 import PasswordResetRequestForm from '../components/PasswordResetRequestForm';
 import PasswordResetExecutionForm from '../components/PasswordResetExecutionForm';
+import CompleteUserRegistrationForm from '~/components/CompleteUserRegistrationForm';
 
 const i18n = i18nFactory();
 
@@ -39,6 +40,7 @@ if (loginFormElem) {
   const name = loginFormElem.dataset.name;
   const email = loginFormElem.dataset.email;
   const isRegistrationEnabled = loginFormElem.dataset.isRegistrationEnabled === 'true';
+  const isEmailAuthenticationEnabled = loginFormElem.dataset.isEmailAuthenticationEnabled === 'true';
   const registrationMode = loginFormElem.dataset.registrationMode;
   const isPasswordResetEnabled = loginFormElem.dataset.isPasswordResetEnabled === 'true';
 
@@ -69,6 +71,7 @@ if (loginFormElem) {
           name={name}
           email={email}
           isRegistrationEnabled={isRegistrationEnabled}
+          isEmailAuthenticationEnabled={isEmailAuthenticationEnabled}
           registrationMode={registrationMode}
           registrationWhiteList={registrationWhiteList}
           isPasswordResetEnabled={isPasswordResetEnabled}
@@ -111,3 +114,25 @@ if (passwordResetExecutionFormElem) {
     passwordResetExecutionFormElem,
   );
 }
+
+// render UserActivationForm
+const UserActivationForm = document.getElementById('user-activation-form');
+if (UserActivationForm) {
+
+  const messageErrors = UserActivationForm.dataset.messageErrors;
+  const inputs = UserActivationForm.dataset.inputs;
+  const email = UserActivationForm.dataset.email;
+  const token = UserActivationForm.dataset.token;
+
+  ReactDOM.render(
+    <I18nextProvider i18n={i18n}>
+      <CompleteUserRegistrationForm
+        messageErrors={messageErrors}
+        inputs={inputs}
+        email={email}
+        token={token}
+      />
+    </I18nextProvider>,
+    UserActivationForm,
+  );
+}

+ 12 - 1
packages/app/src/client/services/AdminLocalSecurityContainer.js

@@ -23,6 +23,7 @@ export default class AdminLocalSecurityContainer extends Container {
       registrationWhiteList: [],
       useOnlyEnvVars: false,
       isPasswordResetEnabled: false,
+      isEmailAuthenticationEnabled: false,
     };
 
   }
@@ -36,6 +37,7 @@ export default class AdminLocalSecurityContainer extends Container {
         registrationMode: localSetting.registrationMode,
         registrationWhiteList: localSetting.registrationWhiteList,
         isPasswordResetEnabled: localSetting.isPasswordResetEnabled,
+        isEmailAuthenticationEnabled: localSetting.isEmailAuthenticationEnabled,
       });
     }
     catch (err) {
@@ -75,15 +77,23 @@ export default class AdminLocalSecurityContainer extends Container {
     this.setState({ isPasswordResetEnabled: !this.state.isPasswordResetEnabled });
   }
 
+  /**
+   * Switch email authentication enabled
+   */
+  switchIsEmailAuthenticationEnabled() {
+    this.setState({ isEmailAuthenticationEnabled: !this.state.isEmailAuthenticationEnabled });
+  }
+
   /**
    * update local security setting
    */
   async updateLocalSecuritySetting() {
-    const { registrationWhiteList, isPasswordResetEnabled } = this.state;
+    const { registrationWhiteList, isPasswordResetEnabled, isEmailAuthenticationEnabled } = this.state;
     const response = await this.appContainer.apiv3.put('/security-setting/local-setting', {
       registrationMode: this.state.registrationMode,
       registrationWhiteList,
       isPasswordResetEnabled,
+      isEmailAuthenticationEnabled,
     });
 
     const { localSettingParams } = response.data;
@@ -92,6 +102,7 @@ export default class AdminLocalSecurityContainer extends Container {
       registrationMode: localSettingParams.registrationMode,
       registrationWhiteList: localSettingParams.registrationWhiteList,
       isPasswordResetEnabled: localSettingParams.isPasswordResetEnabled,
+      isEmailAuthenticationEnabled: localSettingParams.isEmailAuthenticationEnabled,
     });
 
     return localSettingParams;

+ 0 - 1
packages/app/src/client/services/EditorContainer.js

@@ -27,7 +27,6 @@ export default class EditorContainer extends Container {
     this.state = {
       tags: null,
 
-      isSlackEnabled: false,
       slackChannels: mainContent.getAttribute('data-slack-channels') || '',
 
       grant: 1, // default: public

+ 1 - 1
packages/app/src/components/Admin/App/AppSettingsPageContents.jsx

@@ -48,7 +48,7 @@ class AppSettingsPageContents extends React.Component {
 
         <div className="row mt-5">
           <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('admin:app_setting.mail_settings')}</h2>
+            <h2 className="admin-setting-header" id="mail-settings">{t('admin:app_setting.mail_settings')}</h2>
             <MailSetting />
           </div>
         </div>

+ 46 - 2
packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx

@@ -31,9 +31,15 @@ class LocalSecuritySettingContents extends React.Component {
   }
 
   render() {
-    const { t, adminGeneralSecurityContainer, adminLocalSecurityContainer } = this.props;
-    const { registrationMode, isPasswordResetEnabled } = adminLocalSecurityContainer.state;
+    const {
+      t,
+      adminGeneralSecurityContainer,
+      adminLocalSecurityContainer,
+      appContainer,
+    } = this.props;
+    const { registrationMode, isPasswordResetEnabled, isEmailAuthenticationEnabled } = adminLocalSecurityContainer.state;
     const { isLocalEnabled } = adminGeneralSecurityContainer.state;
+    const { isMailerSetup } = appContainer.config;
 
     return (
       <React.Fragment>
@@ -46,6 +52,17 @@ class LocalSecuritySettingContents extends React.Component {
         )}
         <h2 className="alert-anchor border-bottom">{t('security_setting.Local.name')}</h2>
 
+        {!isMailerSetup && (
+          <div className="row">
+            <div className="col-12">
+              <div className="alert alert-danger">
+                <span>{t('security_setting.Local.need_complete_mail_setting_warning')}</span>
+                <a href="/admin/app#mail-settings"> <i className="fa fa-link"></i> {t('admin:app_setting.mail_settings')}</a>
+              </div>
+            </div>
+          </div>
+        )}
+
         {adminLocalSecurityContainer.state.useOnlyEnvVars && (
           <p
             className="alert alert-info"
@@ -178,6 +195,33 @@ class LocalSecuritySettingContents extends React.Component {
               </div>
             </div>
 
+            <div className="row">
+              <label className="col-12 col-md-3 text-left text-md-right  col-form-label">{t('security_setting.Local.email_authentication')}</label>
+              <div className="col-12 col-md-6">
+                <div className="custom-control custom-switch custom-checkbox-success">
+                  <input
+                    type="checkbox"
+                    className="custom-control-input"
+                    id="isEmailAuthenticationEnabled"
+                    checked={isEmailAuthenticationEnabled}
+                    onChange={() => adminLocalSecurityContainer.switchIsEmailAuthenticationEnabled()}
+                  />
+                  <label className="custom-control-label" htmlFor="isEmailAuthenticationEnabled">
+                    {t('security_setting.Local.enable_email_authentication')}
+                  </label>
+                </div>
+                {!isMailerSetup && (
+                  <div className="alert alert-warning p-1 my-1 small d-inline-block">
+                    <span>{t('security_setting.Local.please_enable_mailer')}</span>
+                    <a href="/admin/app#mail-settings"> <i className="fa fa-link"></i> {t('admin:app_setting.mail_settings')}</a>
+                  </div>
+                )}
+                <p className="form-text text-muted small">
+                  {t('security_setting.Local.enable_email_authentication_desc')}
+                </p>
+              </div>
+            </div>
+
             <div className="row my-3">
               <div className="offset-3 col-6">
                 <button

+ 148 - 0
packages/app/src/components/CompleteUserRegistrationForm.tsx

@@ -0,0 +1,148 @@
+import React, { useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
+import { toastSuccess, toastError } from '../client/util/apiNotification';
+
+interface Props {
+  messageErrors?: any,
+  inputs?: any,
+  email: string,
+  token: string,
+}
+
+const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
+
+  const { t } = useTranslation();
+  const {
+    messageErrors,
+    email,
+    token,
+  } = props;
+
+  const [usernameAvailable, setUsernameAvailable] = useState(true);
+  const [username, setUsername] = useState('');
+  const [name, setName] = useState('');
+  const [password, setPassword] = useState('');
+  const [disableForm, setDisableForm] = useState(false);
+
+  useEffect(() => {
+    const delayDebounceFn = setTimeout(async() => {
+      try {
+        const { data } = await apiv3Get('/check_username', { username });
+        if (data.ok) {
+          setUsernameAvailable(data.valid);
+        }
+      }
+      catch (error) {
+        toastError(error, 'Error occurred when checking username');
+      }
+    }, 500);
+
+    return () => clearTimeout(delayDebounceFn);
+  }, [username]);
+
+  async function submitRegistration() {
+    setDisableForm(true);
+    try {
+      await apiv3Post('/complete-registration', {
+        username, name, password, token,
+      });
+      toastSuccess('Registration succeed');
+      window.location.href = '/login';
+    }
+    catch (err) {
+      toastError(err, 'Registration failed');
+      setDisableForm(false);
+    }
+  }
+
+  return (
+    <>
+      <div id="register-form-errors">
+        {messageErrors && (
+          <div className="alert alert-danger">
+            { messageErrors }
+          </div>
+        )}
+      </div>
+      <div id="register-dialog">
+
+        <fieldset id="registration-form" disabled={disableForm}>
+          <input type="hidden" name="token" value={token} />
+          <div className="input-group">
+            <div className="input-group-prepend">
+              <span className="input-group-text"><i className="icon-envelope"></i></span>
+            </div>
+            <input type="text" className="form-control" disabled value={email} />
+          </div>
+          <div className="input-group" id="input-group-username">
+            <div className="input-group-prepend">
+              <span className="input-group-text"><i className="icon-user"></i></span>
+            </div>
+            <input
+              type="text"
+              className="form-control"
+              placeholder={t('User ID')}
+              name="username"
+              onChange={e => setUsername(e.target.value)}
+              required
+            />
+          </div>
+          {!usernameAvailable && (
+            <p className="form-text text-red">
+              <span id="help-block-username"><i className="icon-fw icon-ban"></i>{t('installer.unavaliable_user_id')}</span>
+            </p>
+          )}
+
+          <div className="input-group">
+            <div className="input-group-prepend">
+              <span className="input-group-text"><i className="icon-tag"></i></span>
+            </div>
+            <input
+              type="text"
+              className="form-control"
+              placeholder={t('Name')}
+              name="name"
+              value={name}
+              onChange={e => setName(e.target.value)}
+              required
+            />
+          </div>
+
+          <div className="input-group">
+            <div className="input-group-prepend">
+              <span className="input-group-text"><i className="icon-lock"></i></span>
+            </div>
+            <input
+              type="password"
+              className="form-control"
+              placeholder={t('Password')}
+              name="password"
+              value={password}
+              onChange={e => setPassword(e.target.value)}
+              required
+            />
+          </div>
+
+          <div className="input-group justify-content-center d-flex mt-5">
+            <button type="button" onClick={submitRegistration} className="btn btn-fill" id="register">
+              <div className="eff"></div>
+              <span className="btn-label"><i className="icon-user-follow"></i></span>
+              <span className="btn-label-text">{t('Create')}</span>
+            </button>
+          </div>
+
+          <div className="input-group mt-5 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>
+          </div>
+
+        </fieldset>
+      </div>
+    </>
+  );
+
+};
+
+export default CompleteUserRegistrationForm;

+ 59 - 27
packages/app/src/components/LoginForm.jsx

@@ -148,6 +148,7 @@ class LoginForm extends React.Component {
     const {
       t,
       appContainer,
+      isEmailAuthenticationEnabled,
       username,
       name,
       email,
@@ -155,6 +156,15 @@ class LoginForm extends React.Component {
       registrationWhiteList,
     } = this.props;
 
+    const { isMailerSetup } = appContainer.config;
+    let registerAction = '/register';
+
+    let submitText = t('Sign up');
+    if (isEmailAuthenticationEnabled) {
+      registerAction = '/user-activation/register';
+      submitText = t('page_register.send_email');
+    }
+
     return (
       <React.Fragment>
         {registrationMode === 'Restricted' && (
@@ -164,27 +174,44 @@ class LoginForm extends React.Component {
             {t('page_register.notice.restricted_defail')}
           </p>
         )}
-        <form role="form" action="/register" method="post" id="register-form">
-          <div className="input-group" id="input-group-username">
-            <div className="input-group-prepend">
-              <span className="input-group-text">
-                <i className="icon-user"></i>
-              </span>
-            </div>
-            <input type="text" className="form-control rounded-0" placeholder={t('User ID')} name="registerForm[username]" defaultValue={username} required />
-          </div>
-          <p className="form-text text-danger">
-            <span id="help-block-username"></span>
+        { (!isMailerSetup && isEmailAuthenticationEnabled) && (
+          <p className="alert alert-danger">
+            <span>{t('security_setting.Local.please_enable_mailer')}</span>
           </p>
+        )}
 
-          <div className="input-group">
-            <div className="input-group-prepend">
-              <span className="input-group-text">
-                <i className="icon-tag"></i>
-              </span>
+        <form role="form" action={registerAction} method="post" id="register-form">
+
+          {!isEmailAuthenticationEnabled && (
+            <div>
+              <div className="input-group" id="input-group-username">
+                <div className="input-group-prepend">
+                  <span className="input-group-text">
+                    <i className="icon-user"></i>
+                  </span>
+                </div>
+                <input
+                  type="text"
+                  className="form-control rounded-0"
+                  placeholder={t('User ID')}
+                  name="registerForm[username]"
+                  defaultValue={username}
+                  required
+                />
+              </div>
+              <p className="form-text text-danger">
+                <span id="help-block-username"></span>
+              </p>
+              <div className="input-group">
+                <div className="input-group-prepend">
+                  <span className="input-group-text">
+                    <i className="icon-tag"></i>
+                  </span>
+                </div>
+                <input type="text" className="form-control rounded-0" placeholder={t('Name')} name="registerForm[name]" defaultValue={name} required />
+              </div>
             </div>
-            <input type="text" className="form-control rounded-0" placeholder={t('Name')} name="registerForm[name]" defaultValue={name} required />
-          </div>
+          )}
 
           <div className="input-group">
             <div className="input-group-prepend">
@@ -210,23 +237,27 @@ class LoginForm extends React.Component {
             </>
           )}
 
-          <div className="input-group">
-            <div className="input-group-prepend">
-              <span className="input-group-text">
-                <i className="icon-lock"></i>
-              </span>
+          {!isEmailAuthenticationEnabled && (
+            <div>
+              <div className="input-group">
+                <div className="input-group-prepend">
+                  <span className="input-group-text">
+                    <i className="icon-lock"></i>
+                  </span>
+                </div>
+                <input type="password" className="form-control rounded-0" placeholder={t('Password')} name="registerForm[password]" required />
+              </div>
             </div>
-            <input type="password" className="form-control rounded-0" placeholder={t('Password')} name="registerForm[password]" required />
-          </div>
+          )}
 
           <div className="input-group justify-content-center my-4">
             <input type="hidden" name="_csrf" value={appContainer.csrfToken} />
-            <button type="submit" className="btn btn-fill rounded-0" id="register">
+            <button type="submit" className="btn btn-fill rounded-0" id="register" disabled={(!isMailerSetup && isEmailAuthenticationEnabled)}>
               <div className="eff"></div>
               <span className="btn-label">
                 <i className="icon-user-follow"></i>
               </span>
-              <span className="btn-label-text">{t('Sign up')}</span>
+              <span className="btn-label-text">{submitText}</span>
             </button>
           </div>
         </form>
@@ -314,6 +345,7 @@ LoginForm.propTypes = {
   registrationMode: PropTypes.string,
   registrationWhiteList: PropTypes.array,
   isPasswordResetEnabled: PropTypes.bool,
+  isEmailAuthenticationEnabled: PropTypes.bool,
   isLocalStrategySetup: PropTypes.bool,
   isLdapStrategySetup: PropTypes.bool,
   objOfIsExternalAuthEnableds: PropTypes.object,

+ 1 - 1
packages/app/src/components/PageComment/CommentEditor.jsx

@@ -17,7 +17,7 @@ import GrowiRenderer from '~/client/util/GrowiRenderer';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import Editor from '../PageEditor/Editor';
-import SlackNotification from '../SlackNotification';
+import { SlackNotification } from '../SlackNotification';
 
 import CommentPreview from './CommentPreview';
 import NotAvailableForGuest from '../NotAvailableForGuest';

+ 13 - 14
packages/app/src/components/PageEditor/EditorNavbarBottom.jsx → packages/app/src/components/PageEditor/EditorNavbarBottom.tsx

@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useCallback, useState } from 'react';
 import PropTypes from 'prop-types';
 
 import { Collapse, Button } from 'reactstrap';
@@ -9,13 +9,14 @@ import {
   EditorMode, useDrawerOpened, useEditorMode, useIsDeviceSmallerThanMd,
 } from '~/stores/ui';
 
-import SlackNotification from '../SlackNotification';
+import { SlackNotification } from '../SlackNotification';
 import SlackLogo from '../SlackLogo';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 import SavePageControls from '../SavePageControls';
 
 import OptionsSelector from './OptionsSelector';
+import { useIsSlackEnabled } from '~/stores/editor';
 
 const EditorNavbarBottom = (props) => {
 
@@ -28,9 +29,13 @@ const EditorNavbarBottom = (props) => {
 
   const { mutate: mutateDrawerOpened } = useDrawerOpened();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
-
+  const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
   const additionalClasses = ['grw-editor-navbar-bottom'];
 
+  const isSlackEnabledToggleHandler = useCallback(
+    bool => mutateIsSlackEnabled(bool), [mutateIsSlackEnabled],
+  );
+
   const renderDrawerButton = () => (
     <button
       type="button"
@@ -41,10 +46,6 @@ const EditorNavbarBottom = (props) => {
     </button>
   );
 
-  const slackEnabledFlagChangedHandler = (isSlackEnabled) => {
-    props.editorContainer.setState({ isSlackEnabled });
-  };
-
   const slackChannelsChangedHandler = (slackChannels) => {
     props.editorContainer.setState({ slackChannels });
   };
@@ -69,15 +70,14 @@ const EditorNavbarBottom = (props) => {
     <div className={`${isCollapsedOptionsSelectorEnabled ? 'fixed-bottom' : ''} `}>
       {/* Collapsed SlackNotification */}
       {isSlackConfigured && (
-        <Collapse isOpen={isSlackExpanded && isDeviceSmallerThanMd}>
+        <Collapse isOpen={isSlackExpanded && isDeviceSmallerThanMd === true}>
           <nav className={`navbar navbar-expand-lg border-top ${additionalClasses.join(' ')}`}>
             <SlackNotification
-              isSlackEnabled={props.editorContainer.state.isSlackEnabled}
+              isSlackEnabled={isSlackEnabled ?? false}
               slackChannels={props.editorContainer.state.slackChannels}
-              onEnabledFlagChange={slackEnabledFlagChangedHandler}
+              onEnabledFlagChange={isSlackEnabledToggleHandler}
               onChannelChange={slackChannelsChangedHandler}
               id="idForEditorNavbarBottomForMobile"
-              popUp
             />
           </nav>
         </Collapse>
@@ -104,12 +104,11 @@ const EditorNavbarBottom = (props) => {
           ) : (
             <div className="mr-2">
               <SlackNotification
-                isSlackEnabled={props.editorContainer.state.isSlackEnabled}
+                isSlackEnabled={isSlackEnabled ?? false}
                 slackChannels={props.editorContainer.state.slackChannels}
-                onEnabledFlagChange={slackEnabledFlagChangedHandler}
+                onEnabledFlagChange={isSlackEnabledToggleHandler}
                 onChannelChange={slackChannelsChangedHandler}
                 id="idForEditorNavbarBottom"
-                popUp={false}
               />
             </div>
           ))}

+ 0 - 87
packages/app/src/components/SlackNotification.jsx

@@ -1,87 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-import { UncontrolledPopover, PopoverHeader, PopoverBody } from 'reactstrap';
-/**
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @export
- * @class SlackNotification
- * @extends {React.Component}
- */
-
-class SlackNotification extends React.Component {
-
-  constructor(props) {
-    super(props);
-    this.idForSlackPopover = `${this.props.id}ForSlackPopover`;
-    this.updateCheckboxHandler = this.updateCheckboxHandler.bind(this);
-    this.updateSlackChannelsHandler = this.updateSlackChannelsHandler.bind(this);
-  }
-
-  updateCheckboxHandler(event) {
-    const value = event.target.checked;
-    if (this.props.onEnabledFlagChange != null) {
-      this.props.onEnabledFlagChange(value);
-    }
-  }
-
-  updateSlackChannelsHandler(event) {
-    const value = event.target.value;
-    if (this.props.onChannelChange != null) {
-      this.props.onChannelChange(value);
-    }
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <div className="grw-slack-notification w-100">
-        <div className="grw-input-group-slack-notification input-group extended-setting">
-          <label className="input-group-addon">
-            <div className="custom-control custom-switch custom-switch-lg custom-switch-slack">
-              <input
-                type="checkbox"
-                className="custom-control-input border-0"
-                id={this.props.id}
-                checked={this.props.isSlackEnabled}
-                onChange={this.updateCheckboxHandler}
-              />
-              <label className="custom-control-label align-center" htmlFor={this.props.id}>
-              </label>
-            </div>
-          </label>
-          <input
-            className="grw-form-control-slack-notification form-control align-top pl-0"
-            id={this.idForSlackPopover}
-            type="text"
-            value={this.props.slackChannels}
-            placeholder="Input channels"
-            onChange={this.updateSlackChannelsHandler}
-          />
-          <UncontrolledPopover trigger="focus" placement="top" target={this.idForSlackPopover}>
-            <PopoverHeader>{t('slack_notification.popover_title')}</PopoverHeader>
-            <PopoverBody>{t('slack_notification.popover_desc')}</PopoverBody>
-          </UncontrolledPopover>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-SlackNotification.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  popUp: PropTypes.bool.isRequired,
-  isSlackEnabled: PropTypes.bool.isRequired,
-  slackChannels: PropTypes.string.isRequired,
-  onEnabledFlagChange: PropTypes.func,
-  onChannelChange: PropTypes.func,
-  id: PropTypes.string.isRequired,
-};
-
-export default withTranslation()(SlackNotification);

+ 67 - 0
packages/app/src/components/SlackNotification.tsx

@@ -0,0 +1,67 @@
+/* eslint-disable react/prop-types */
+import React, { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PopoverBody, PopoverHeader, UncontrolledPopover } from 'reactstrap';
+
+
+type SlackNotificationProps = {
+  id: string;
+  isSlackEnabled: boolean;
+  slackChannels: string;
+  onEnabledFlagChange?: (isSlackEnabled: boolean) => void;
+  onChannelChange?: (value: string) => void;
+};
+
+export const SlackNotification: FC<SlackNotificationProps> = ({
+  id, isSlackEnabled, slackChannels, onEnabledFlagChange, onChannelChange,
+}) => {
+  const { t } = useTranslation();
+  const idForSlackPopover = `${id}ForSlackPopover`;
+
+  const updateCheckboxHandler = (event: { target: { checked: boolean }; }) => {
+    const value = event.target.checked;
+    if (onEnabledFlagChange != null) {
+      onEnabledFlagChange(value);
+    }
+  };
+
+  const updateSlackChannelsHandler = (event: { target: { value: string } }) => {
+    const value = event.target.value;
+    if (onChannelChange != null) {
+      onChannelChange(value);
+    }
+  };
+
+
+  return (
+    <div className="grw-slack-notification w-100">
+      <div className="grw-input-group-slack-notification input-group extended-setting">
+        <label className="input-group-addon">
+          <div className="custom-control custom-switch custom-switch-lg custom-switch-slack">
+            <input
+              type="checkbox"
+              className="custom-control-input border-0"
+              id={id}
+              checked={isSlackEnabled}
+              onChange={updateCheckboxHandler}
+            />
+            <label className="custom-control-label align-center" htmlFor={id}></label>
+          </div>
+        </label>
+        <input
+          className="grw-form-control-slack-notification form-control align-top pl-0"
+          id={idForSlackPopover}
+          type="text"
+          value={slackChannels}
+          placeholder="Input channels"
+          onChange={updateSlackChannelsHandler}
+        />
+        <UncontrolledPopover trigger="focus" placement="top" target={idForSlackPopover}>
+          <PopoverHeader>{t('slack_notification.popover_title')}</PopoverHeader>
+          <PopoverBody>{t('slack_notification.popover_desc')}</PopoverBody>
+        </UncontrolledPopover>
+      </div>
+    </div>
+
+  );
+};

+ 22 - 0
packages/app/src/server/middlewares/inject-user-registration-order-by-token-middleware.ts

@@ -0,0 +1,22 @@
+import createError from 'http-errors';
+
+import UserRegistrationOrder from '../models/user-registration-order';
+
+export default async(req, res, next): Promise<void> => {
+  const token = req.params.token || req.body.token;
+
+  if (token == null) {
+    return next(createError(400, 'Token not found', { code: 'token-not-found' }));
+  }
+
+  const userRegistrationOrder = await UserRegistrationOrder.findOne({ token });
+
+  // check if the token is valid
+  if (userRegistrationOrder == null || userRegistrationOrder.isExpired() || userRegistrationOrder.isRevoked) {
+    return next(createError(400, 'userRegistrationOrder is null or expired or revoked', { code: 'password-reset-order-is-not-appropriate' }));
+  }
+
+  req.userRegistrationOrder = userRegistrationOrder;
+
+  return next();
+};

+ 67 - 0
packages/app/src/server/models/user-registration-order.ts

@@ -0,0 +1,67 @@
+import mongoose, {
+  Schema, Model, Document,
+} from 'mongoose';
+
+import uniqueValidator from 'mongoose-unique-validator';
+import crypto from 'crypto';
+import { getOrCreateModel } from '@growi/core';
+
+export interface IUserRegistrationOrder {
+  token: string,
+  email: string,
+  isRevoked: boolean,
+  createdAt: Date,
+  expiredAt: Date,
+}
+
+export interface UserRegistrationOrderDocument extends IUserRegistrationOrder, Document {
+  isExpired(): Promise<boolean>
+  revokeOneTimeToken(): Promise<void>
+}
+
+export interface UserRegistrationOrderModel extends Model<UserRegistrationOrderDocument> {
+  generateOneTimeToken(): string
+  createUserRegistrationOrder(email: string): UserRegistrationOrderDocument
+}
+
+const schema = new Schema<UserRegistrationOrderDocument, UserRegistrationOrderModel>({
+  token: { type: String, required: true, unique: true },
+  email: { type: String, required: true },
+  isRevoked: { type: Boolean, default: false, required: true },
+  createdAt: { type: Date, default: new Date(Date.now()), required: true },
+  expiredAt: { type: Date, default: new Date(Date.now() + 600000), required: true },
+});
+schema.plugin(uniqueValidator);
+
+schema.statics.generateOneTimeToken = function() {
+  const buf = crypto.randomBytes(256);
+  const token = buf.toString('hex');
+
+  return token;
+};
+
+schema.statics.createUserRegistrationOrder = async function(email) {
+  let token;
+  let duplicateToken;
+
+  do {
+    token = this.generateOneTimeToken();
+    // eslint-disable-next-line no-await-in-loop
+    duplicateToken = await this.findOne({ token });
+  } while (duplicateToken != null);
+
+  const userRegistrationOrderData = await this.create({ token, email });
+
+  return userRegistrationOrderData;
+};
+
+schema.methods.isExpired = function() {
+  return this.expiredAt.getTime() < Date.now();
+};
+
+schema.methods.revokeOneTimeToken = async function() {
+  this.isRevoked = true;
+  return this.save();
+};
+
+export default getOrCreateModel<UserRegistrationOrderDocument, UserRegistrationOrderModel>('UserRegistrationOrder', schema);

+ 10 - 0
packages/app/src/server/models/user.js

@@ -483,6 +483,16 @@ module.exports = function(crowi) {
     return usernameUsable;
   };
 
+  userSchema.statics.isRegisterableEmail = async function(email) {
+    let isEmailUsable = true;
+
+    const userData = await this.findOne({ email });
+    if (userData) {
+      isEmailUsable = false;
+    }
+    return isEmailUsable;
+  };
+
   userSchema.statics.isRegisterable = function(email, username, callback) {
     const User = this;
     let emailUsable = true;

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

@@ -1,4 +1,6 @@
 import loggerFactory from '~/utils/logger';
+import * as userActivation from './user-activation';
+import injectUserRegistrationOrderByTokenMiddleware from '../../middlewares/inject-user-registration-order-by-token-middleware';
 
 import pageListing from './page-listing';
 
@@ -57,7 +59,17 @@ module.exports = (crowi) => {
 
   router.use('/forgot-password', require('./forgot-password')(crowi));
 
+  const user = require('../user')(crowi, null);
+  router.get('/check_username', user.api.checkUsername);
+
+  router.post('/complete-registration',
+    injectUserRegistrationOrderByTokenMiddleware,
+    userActivation.completeRegistrationRules(),
+    userActivation.validateCompleteRegistration,
+    userActivation.completeRegistrationAction(crowi));
+
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
 
+
   return router;
 };

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

@@ -381,6 +381,7 @@ module.exports = (crowi) => {
         registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
         registrationWhiteList: await crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
         isPasswordResetEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
+        isEmailAuthenticationEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled'),
       },
       generalAuth: {
         isLocalEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isEnabled'),
@@ -749,6 +750,7 @@ module.exports = (crowi) => {
       'security:registrationMode': req.body.registrationMode,
       'security:registrationWhiteList': req.body.registrationWhiteList,
       'security:passport-local:isPasswordResetEnabled': req.body.isPasswordResetEnabled,
+      'security:passport-local:isEmailAuthenticationEnabled': req.body.isEmailAuthenticationEnabled,
     };
     try {
       await updateAndReloadStrategySettings('local', requestParams);
@@ -757,6 +759,7 @@ module.exports = (crowi) => {
         registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
         registrationWhiteList: await crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
         isPasswordResetEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
+        isEmailAuthenticationEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled'),
       };
       return res.apiv3({ localSettingParams });
     }

+ 138 - 0
packages/app/src/server/routes/apiv3/user-activation.ts

@@ -0,0 +1,138 @@
+import path from 'path';
+import * as express from 'express';
+import { body, validationResult } from 'express-validator';
+import ErrorV3 from '../../models/vo/error-apiv3';
+
+// validation rules for complete registration form
+export const completeRegistrationRules = () => {
+  return [
+    body('username')
+      .matches(/^[\da-zA-Z\-_.]+$/)
+      .withMessage('Username has invalid characters')
+      .not()
+      .isEmpty()
+      .withMessage('Username field is required'),
+    body('name').not().isEmpty().withMessage('Name field is required'),
+    body('token').not().isEmpty().withMessage('Token value is required'),
+    body('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'),
+  ];
+};
+
+// middleware to validate complete registration form
+export const validateCompleteRegistration = (req, res, next) => {
+  const errors = validationResult(req);
+  if (errors.isEmpty()) {
+    return next();
+  }
+
+  const extractedErrors: string[] = [];
+  errors.array().map(err => extractedErrors.push(err.msg));
+
+  return res.apiv3Err(extractedErrors);
+};
+
+async function sendEmailToAllAdmins(userData, admins, appTitle, mailService, template, url) {
+  const promises = admins.map((admin) => {
+    return mailService.send({
+      to: admin.email,
+      subject: `[${appTitle}:admin] A New User Created and Waiting for Activation`,
+      template,
+      vars: {
+        createdUser: userData,
+        admin,
+        url,
+        appTitle,
+      },
+    });
+  });
+}
+
+export const completeRegistrationAction = (crowi) => {
+  const User = crowi.model('User');
+  const {
+    configManager,
+    aclService,
+    appService,
+    mailService,
+  } = crowi;
+
+  return async function(req, res) {
+    if (req.user != null) {
+      return res.apiv3Err(new ErrorV3('You have been logged in', 'registration-failed'), 403);
+    }
+
+    // config で closed ならさよなら
+    if (configManager.getConfig('crowi', 'security:registrationMode') === aclService.labels.SECURITY_REGISTRATION_MODE_CLOSED) {
+      return res.apiv3Err(new ErrorV3('Registration closed', 'registration-failed'), 403);
+    }
+
+    const { userRegistrationOrder } = req;
+    const registerForm = req.body;
+
+    const email = userRegistrationOrder.email;
+    const name = registerForm.name;
+    const username = registerForm.username;
+    const password = registerForm.password;
+
+    // email と username の unique チェックする
+    User.isRegisterable(email, username, (isRegisterable, errOn) => {
+      let isError = false;
+      let errorMessage = '';
+      if (!User.isEmailValid(email)) {
+        isError = true;
+        errorMessage += req.t('message.email_address_could_not_be_used');
+      }
+      if (!isRegisterable) {
+        if (!errOn.username) {
+          isError = true;
+          errorMessage += req.t('message.user_id_is_not_available');
+        }
+        if (!errOn.email) {
+          isError = true;
+          errorMessage += req.t('message.email_address_is_already_registered');
+        }
+      }
+      if (isError) {
+        return res.apiv3Err(new ErrorV3(errorMessage, 'registration-failed'), 403);
+      }
+
+      if (configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled') === true) {
+        User.createUserByEmailAndPassword(name, username, email, password, undefined, async(err, userData) => {
+          if (err) {
+            if (err.name === 'UserUpperLimitException') {
+              errorMessage = req.t('message.can_not_register_maximum_number_of_users');
+            }
+            else {
+              errorMessage = req.t('message.failed_to_register');
+            }
+            return res.apiv3Err(new ErrorV3(errorMessage, 'registration-failed'), 403);
+          }
+
+          userRegistrationOrder.revokeOneTimeToken();
+
+          if (configManager.getConfig('crowi', 'security:registrationMode') !== aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
+            const admins = await User.findAdmins();
+            const appTitle = appService.getAppTitle();
+            const template = path.join(crowi.localeDir, 'en_US/admin/userWaitingActivation.txt');
+            const url = appService.getSiteUrl();
+
+            sendEmailToAllAdmins(userData, admins, appTitle, mailService, template, url);
+          }
+
+          req.flash('successMessage', req.t('message.successfully_created', { username }));
+          res.apiv3({ status: 'ok' });
+        });
+      }
+      else {
+        return res.apiv3Err(new ErrorV3('Email authentication configuration is disabled', 'registration-failed'), 403);
+      }
+    });
+  };
+};

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

@@ -1,9 +1,11 @@
 import express from 'express';
 
 import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
+import injectUserRegistrationOrderByTokenMiddleware from '../middlewares/inject-user-registration-order-by-token-middleware';
 
 import * as forgotPassword from './forgot-password';
 import * as privateLegacyPages from './private-legacy-pages';
+import * as userActivation from './user-activation';
 
 const multer = require('multer');
 const autoReap = require('multer-autoreap');
@@ -195,6 +197,10 @@ module.exports = function(crowi, app) {
 
   app.use('/private-legacy-pages', express.Router()
     .get('/', privateLegacyPages.renderPrivateLegacyPages));
+  app.use('/user-activation', express.Router()
+    .get('/:token', apiLimiter, applicationInstalled, injectUserRegistrationOrderByTokenMiddleware, userActivation.form)
+    .use(userActivation.tokenErrorHandlerMiddeware));
+  app.post('/user-activation/register', apiLimiter, applicationInstalled, csrf, userActivation.registerRules(), userActivation.validateRegisterForm, userActivation.registerAction(crowi));
 
   app.get('/share/:linkId', page.showSharedPage);
 

+ 114 - 0
packages/app/src/server/routes/user-activation.ts

@@ -0,0 +1,114 @@
+import path from 'path';
+import { body, validationResult } from 'express-validator';
+import UserRegistrationOrder from '../models/user-registration-order';
+
+export const form = (req, res): void => {
+  const { userRegistrationOrder } = req;
+  return res.render('user-activation', { userRegistrationOrder });
+};
+
+async function makeRegistrationEmailToken(email, crowi) {
+  const {
+    configManager,
+    mailService,
+    localeDir,
+    appService,
+  } = crowi;
+
+  const grobalLang = configManager.getConfig('crowi', 'app:globalLang');
+  const i18n = grobalLang;
+  const appUrl = appService.getSiteUrl();
+
+  const userRegistrationOrder = await UserRegistrationOrder.createUserRegistrationOrder(email);
+  const url = new URL(`/user-activation/${userRegistrationOrder.token}`, appUrl);
+  const oneTimeUrl = url.href;
+  const txtFileName = 'userActivation';
+
+  return mailService.send({
+    to: email,
+    subject: txtFileName,
+    template: path.join(localeDir, `${i18n}/notifications/${txtFileName}.txt`),
+    vars: {
+      appTitle: appService.getAppTitle(),
+      email,
+      url: oneTimeUrl,
+    },
+  });
+}
+
+export const registerAction = (crowi) => {
+  const User = crowi.model('User');
+
+  return async function(req, res) {
+    const registerForm = req.body.registerForm || {};
+    const email = registerForm.email;
+    const isRegisterableEmail = await User.isRegisterableEmail(email);
+
+    if (!isRegisterableEmail) {
+      req.body.registerForm.email = email;
+      req.flash('registerWarningMessage', req.t('message.email_address_is_already_registered'));
+      req.flash('email', email);
+
+      return res.redirect('/login#register');
+    }
+
+    makeRegistrationEmailToken(email, crowi);
+
+    req.flash('successMessage', req.t('message.successfully_send_email_auth', { email }));
+
+    return res.redirect('/login');
+  };
+};
+
+// middleware to handle error
+export const tokenErrorHandlerMiddeware = (err, req, res, next) => {
+  if (err != null) {
+    req.flash('errorMessage', req.t('message.incorrect_token_or_expired_url'));
+    return res.redirect('/login#register');
+  }
+  next();
+};
+
+// validation rules for registration form when email authentication enabled
+export const registerRules = () => {
+  return [
+    body('registerForm.email')
+      .isEmail()
+      .withMessage('Email format is invalid.')
+      .exists()
+      .withMessage('Email field is required.'),
+  ];
+};
+
+// middleware to validate complete registration form
+export const validateCompleteRegistrationForm = (req, res, next) => {
+  const errors = validationResult(req);
+  if (errors.isEmpty()) {
+    return next();
+  }
+
+  const extractedErrors: string[] = [];
+  errors.array().map(err => extractedErrors.push(err.msg));
+
+  req.flash('errors', extractedErrors);
+  req.flash('inputs', req.body);
+
+  const token = req.body.token;
+  return res.redirect(`/user-activation/${token}`);
+};
+
+// middleware to validate register form if email authentication enabled
+export const validateRegisterForm = (req, res, next) => {
+  const errors = validationResult(req);
+  if (errors.isEmpty()) {
+    return next();
+  }
+
+  req.form = { isValid: false };
+  const extractedErrors: string[] = [];
+  errors.array().map(err => extractedErrors.push(err.msg));
+
+  req.flash('registerWarningMessage', extractedErrors);
+
+  res.redirect('back');
+};

+ 9 - 6
packages/app/src/server/routes/user.js

@@ -56,20 +56,23 @@ module.exports = function(crowi, app) {
 
   actions.api = api;
 
-  api.checkUsername = function(req, res) {
+  api.checkUsername = async function(req, res) {
     const username = req.query.username;
 
-    User.findUserByUsername(username)
+    let valid = false;
+    await User.findUserByUsername(username)
       .then((userData) => {
         if (userData) {
-          return res.json({ valid: false });
+          valid = false;
+        }
+        else {
+          valid = true;
         }
-
-        return res.json({ valid: true });
       })
       .catch((err) => {
-        return res.json({ valid: true });
+        valid = false;
       });
+    return res.json(ApiResponse.success({ valid }));
   };
 
   /**

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

@@ -313,6 +313,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.BOOLEAN,
     default: true,
   },
+  LOCAL_STRATEGY_EMAIL_AUTHENTICATION_ENABLED: {
+    ns:      'crowi',
+    key:     'security:passport-local:isEmailAuthenticationEnabled',
+    type:    ValueType.BOOLEAN,
+    default: false,
+  },
   SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: {
     ns:      'crowi',
     key:     'security:passport-saml:useOnlyEnvVarsForSomeOptions',

+ 3 - 2
packages/app/src/server/views/login.html

@@ -110,17 +110,18 @@
       {% set registrationMode = getConfig('crowi', 'security:registrationMode') %}
       {% set isRegistrationEnabled = passportService.isLocalStrategySetup && registrationMode != 'Closed' %}
       {% set isPasswordResetEnabled = getConfig('crowi', 'security:passport-local:isPasswordResetEnabled') %}
-
+      {% set isEmailAuthenticationEnabled = getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled') %}
       <div
         id="login-form"
         data-is-registering="{{ req.query.register or req.body.registerForm or isRegistering }}"
         data-username ="{{ req.body.registerForm.username }}"
         data-name ="{{ req.body.registerForm.name }}"
-        data-email ="{{ req.body.registerForm.email }}"
+        data-email ="{{ req.body.registerForm.email || req.flash('email') }}"
         data-is-registration-enabled="{{ isRegistrationEnabled }}"
         data-registration-mode = "{{ registrationMode }}"
         data-registration-white-list = "{{ getConfig('crowi', 'security:registrationWhiteList') }}"
         data-is-password-reset-enabled = "{{ isPasswordResetEnabled }}"
+        data-is-email-authentication-enabled = "{{ isEmailAuthenticationEnabled }}"
         data-is-local-strategy-setup = "{{ passportService.isLocalStrategySetup }}"
         data-is-ldap-strategy-setup = "{{ passportService.isLdapStrategySetup}}"
         data-is-google-auth-enabled = "{{ getConfig('crowi', 'security:passport-google:isEnabled') }}"

+ 52 - 0
packages/app/src/server/views/user-activation.html

@@ -0,0 +1,52 @@
+{% extends 'layout/layout.html' %}
+
+{% block html_base_css %}invited nologin{% endblock %}
+
+{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName('Registration') }}{% endblock %}
+
+
+
+{#
+# Remove default contents
+#}
+{% block html_head_loading_legacy %}
+{% endblock %}
+{% block html_head_loading_app %}
+{% endblock %}
+{% block layout_head_nav %}
+{% endblock %}
+{% block sidebar %}
+{% endblock %}
+{% block head_warn_alert_siteurl_undefined %}
+{% endblock %}
+{% block fixed-controls %}
+{% endblock %}
+
+{% block html_additional_headers %}
+  <script src="{{ webpack_asset('js/nologin.js') }}" defer></script>
+{% endblock %}
+
+{% block layout_main %}
+
+<div class="main container-fluid">
+
+  <div class="row">
+
+    <div class="login-header mx-auto col-sm-3">
+      <div class="logo">{% include 'widget/logo.html' %}</div>
+      <h1>GROWI</h1>
+
+      <div
+        id="user-activation-form"
+        data-message-errors="{{ req.flash('errors') }}"
+        data-inputs="{{ req.flash('inputs') }}"
+        data-email="{{ userRegistrationOrder.email }}"
+        data-token="{{ userRegistrationOrder.token }}"
+        class="col-sm-12"
+      ></div>
+
+  </div>{# /.row #}
+
+</div>{# /.main #}
+
+{% endblock %}

+ 9 - 0
packages/app/src/stores/editor.tsx

@@ -0,0 +1,9 @@
+import { SWRResponse } from 'swr';
+import { useStaticSWR } from './use-static-swr';
+
+export const useIsSlackEnabled = (isEnabled?: boolean): SWRResponse<boolean, Error> => {
+  const initialData = false;
+  return (
+    useStaticSWR('isSlackEnabled', isEnabled || null, { fallbackData: initialData })
+  );
+};

+ 1 - 0
packages/app/src/styles/_layout.scss

@@ -1,5 +1,6 @@
 body {
   overflow-y: scroll !important;
+  overscroll-behavior: none;
 }
 
 body:not(.growi-layout-fluid) .grw-container-convertible {

+ 2 - 0
packages/app/src/styles/_sidebar.scss

@@ -22,6 +22,8 @@
   position: sticky;
   top: $grw-navbar-border-width;
 
+  height: 100vh;
+
   .grw-navigation-resize-button {
     position: fixed;
 

+ 4 - 1
packages/app/src/styles/atoms/_buttons.scss

@@ -47,10 +47,13 @@
   overflow: hidden;
   color: white;
   text-align: center;
-  cursor: pointer;
   background-color: rgba(lighten(black, 15%), 0.5);
   border: none;
 
+  &:not(:disabled) {
+    cursor: pointer;
+  }
+
   .btn-label {
     position: relative;
     z-index: 1;

+ 46 - 58
yarn.lock

@@ -4050,21 +4050,20 @@ autoprefixer@^9.0.0:
     postcss "^7.0.0"
     postcss-value-parser "^3.2.3"
 
-aws-sdk@^2.2.36, aws-sdk@^2.88.0:
-  version "2.179.0"
-  resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.179.0.tgz#48e07843c6ae83d6752e58547b168299f140cc11"
-  dependencies:
-    buffer "4.9.1"
-    create-hash "^1.1.3"
-    create-hmac "^1.1.6"
-    events "^1.1.1"
+aws-sdk@^2.1044.0, aws-sdk@^2.2.36:
+  version "2.1044.0"
+  resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.1044.0.tgz#0708eaf48daf8d961b414e698d84e8cd1f82c4ad"
+  integrity sha512-n55uGUONQGXteGGG1QlZ1rKx447KSuV/x6jUGNf2nOl41qMI8ZgLUhNUt0uOtw3qJrCTanzCyR/JKBq2PMiqEQ==
+  dependencies:
+    buffer "4.9.2"
+    events "1.1.1"
+    ieee754 "1.1.13"
     jmespath "0.15.0"
     querystring "0.2.0"
     sax "1.2.1"
     url "0.10.3"
-    uuid "3.1.0"
-    xml2js "0.4.17"
-    xmlbuilder "4.2.1"
+    uuid "3.3.2"
+    xml2js "0.4.19"
 
 aws-sign2@~0.7.0:
   version "0.7.0"
@@ -4734,9 +4733,10 @@ buffer-xor@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9"
 
-buffer@4.9.1, buffer@^4.3.0:
-  version "4.9.1"
-  resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.1.tgz#6d1bb601b07a4efced97094132093027c95bc298"
+buffer@4.9.2, buffer@^4.3.0:
+  version "4.9.2"
+  resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.2.tgz#230ead344002988644841ab0244af8c44bbe3ef8"
+  integrity sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==
   dependencies:
     base64-js "^1.0.2"
     ieee754 "^1.1.4"
@@ -6240,7 +6240,7 @@ create-error-class@^3.0.0:
   dependencies:
     capture-stack-trace "^1.0.0"
 
-create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.1.3:
+create-hash@^1.1.0, create-hash@^1.1.2:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.1.3.tgz#606042ac8b9262750f483caddab0f5819172d8fd"
   dependencies:
@@ -6249,7 +6249,7 @@ create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.1.3:
     ripemd160 "^2.0.0"
     sha.js "^2.4.0"
 
-create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4, create-hmac@^1.1.6:
+create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4:
   version "1.1.6"
   resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.6.tgz#acb9e221a4e17bdb076e90657c42b93e3726cf06"
   dependencies:
@@ -8009,7 +8009,7 @@ eventemitter3@^4.0.4:
   resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
   integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
 
-events@^1.1.1:
+events@1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924"
 
@@ -10024,15 +10024,16 @@ icss-utils@^4.0.0, icss-utils@^4.1.1:
   dependencies:
     postcss "^7.0.14"
 
-ieee754@^1.1.13, ieee754@^1.2.1:
+ieee754@1.1.13:
+  version "1.1.13"
+  resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
+  integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==
+
+ieee754@^1.1.13, ieee754@^1.1.4, ieee754@^1.2.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
   integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
 
-ieee754@^1.1.4:
-  version "1.1.8"
-  resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.8.tgz#be33d40ac10ef1926701f6f08a2d86fbfd1ad3e4"
-
 iferr@^0.1.5:
   version "0.1.5"
   resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501"
@@ -12283,7 +12284,7 @@ lodash.uniqwith@^4.5.0:
   resolved "https://registry.yarnpkg.com/lodash.uniqwith/-/lodash.uniqwith-4.5.0.tgz#7a0cbf65f43b5928625a9d4d0dc54b18cadc7ef3"
   integrity sha1-egy/ZfQ7WShiWp1NDcVLGMrcfvM=
 
-lodash@4.x, lodash@>=4.17.15, lodash@^4.0.0, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.2, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.7.0, lodash@~4.17.21:
+lodash@4.x, lodash@>=4.17.15, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.2, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.7.0, lodash@~4.17.21:
   version "4.17.21"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
   integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@@ -17761,18 +17762,9 @@ set-immediate-shim@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61"
 
-set-value@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/set-value/-/set-value-0.4.3.tgz#7db08f9d3d22dc7f78e53af3c3bf4666ecdfccf1"
-  dependencies:
-    extend-shallow "^2.0.1"
-    is-extendable "^0.1.1"
-    is-plain-object "^2.0.1"
-    to-object-path "^0.3.0"
-
-set-value@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.0.tgz#71ae4a88f0feefbbf52d1ea604f3fb315ebb6274"
+set-value@^2.0.0, set-value@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b"
   dependencies:
     extend-shallow "^2.0.1"
     is-extendable "^0.1.1"
@@ -20424,13 +20416,13 @@ unified@^9.2.1:
     vfile "^4.0.0"
 
 union-value@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.0.tgz#5c71c34cb5bad5dcebe3ea0cd08207ba5aa1aea4"
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847"
   dependencies:
     arr-union "^3.1.0"
     get-value "^2.0.6"
     is-extendable "^0.1.1"
-    set-value "^0.4.3"
+    set-value "^2.0.1"
 
 uniq@^1.0.1:
   version "1.0.1"
@@ -20784,21 +20776,17 @@ utils-merge@1.0.1, utils-merge@1.x.x:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
 
-uuid@3.1.0, uuid@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04"
+uuid@3.3.2:
+  version "3.3.2"
+  resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"
+  integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==
 
-uuid@8.3.2, uuid@^8.0.0:
+uuid@8.3.2, uuid@>=8.1.0, uuid@^8.0.0:
   version "8.3.2"
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
   integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
 
-uuid@>=8.1.0:
-  version "8.1.0"
-  resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.1.0.tgz#6f1536eb43249f473abc6bd58ff983da1ca30d8d"
-  integrity sha512-CI18flHDznR0lq54xBycOVmphdCYnQLKn8abKn7PXUiKUGdEd+/l9LWNJmugXel4hXq7S+RMNl34ecyC9TntWg==
-
-uuid@^3.0.1, uuid@^3.3.2:
+uuid@^3.0.1, uuid@^3.1.0, uuid@^3.3.2:
   version "3.4.0"
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
   integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
@@ -21431,12 +21419,13 @@ xml-name-validator@^3.0.0:
   resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
   integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==
 
-xml2js@0.4.17:
-  version "0.4.17"
-  resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.17.tgz#17be93eaae3f3b779359c795b419705a8817e868"
+xml2js@0.4.19:
+  version "0.4.19"
+  resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.19.tgz#686c20f213209e94abf0d1bcf1efaa291c7827a7"
+  integrity sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==
   dependencies:
     sax ">=0.6.0"
-    xmlbuilder "^4.1.0"
+    xmlbuilder "~9.0.1"
 
 xml2js@^0.4.23:
   version "0.4.23"
@@ -21446,12 +21435,6 @@ xml2js@^0.4.23:
     sax ">=0.6.0"
     xmlbuilder "~11.0.0"
 
-xmlbuilder@4.2.1, xmlbuilder@^4.1.0:
-  version "4.2.1"
-  resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-4.2.1.tgz#aa58a3041a066f90eaa16c2f5389ff19f3f461a5"
-  dependencies:
-    lodash "^4.0.0"
-
 xmlbuilder@^15.1.1:
   version "15.1.1"
   resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz#9dcdce49eea66d8d10b42cae94a79c3c8d0c2ec5"
@@ -21462,6 +21445,11 @@ xmlbuilder@~11.0.0:
   resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3"
   integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==
 
+xmlbuilder@~9.0.1:
+  version "9.0.7"
+  resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d"
+  integrity sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=
+
 xmlchars@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"