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

Merge pull request #6664 from weseek/imprv/use-xhr-installer-base

imprv: Use xhr installer base
Haku Mizuki 3 лет назад
Родитель
Сommit
5bc51e51af

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

@@ -191,7 +191,9 @@
     "setup": "Setup",
     "create_initial_account": "Create an initial account",
     "initial_account_will_be_administrator_automatically": "The initial account will be administrator automatically.",
-    "unavaliable_user_id": "This 'User ID' is unavailable."
+    "unavaliable_user_id": "This 'User ID' is unavailable.",
+    "failed_to_install": "Failed to install GROWI. Please try again.",
+    "failed_to_login_after_install": "Failed to login after installation. Redirecting to the login form ..."
   },
   "breaking_changes": {
     "v346_using_basic_auth": "Basic Authentication currently in use will <strong>no longer be available</strong> in the near future. Remove settings from %s"

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

@@ -184,7 +184,9 @@
     "setup": "セットアップ",
     "create_initial_account": "最初のアカウントの作成",
     "initial_account_will_be_administrator_automatically": "初めに作成するアカウントは、自動的に管理者権限が付与されます",
-    "unavaliable_user_id": "このユーザーIDは利用できません。"
+    "unavaliable_user_id": "このユーザーIDは利用できません。",
+    "failed_to_install": "GROWI のインストールに失敗しました。再度お試しください。",
+    "failed_to_login_after_install": "インストール後、ログインに失敗しました。ログインフォームに遷移しています ..."
   },
   "breaking_changes": {
     "v346_using_basic_auth": "現在利用中の Basic 認証機能は、近い将来<strong>廃止されます</strong>。%s から設定を削除してください。"

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

@@ -186,7 +186,9 @@
 		"setup": "安装",
 		"create_initial_account": "创建初始用户",
 		"initial_account_will_be_administrator_automatically": "初始帐户将自动成为管理员。",
-		"unavaliable_user_id": "用户ID不可用"
+		"unavaliable_user_id": "用户ID不可用",
+    "failed_to_install": "GROWI安装失败。请再试一次。",
+    "failed_to_login_after_install": "安装后登录失败。重定向到登录表格..."
 	},
 	"breaking_changes": {
 		"v346_using_basic_auth": "当前使用的基本身份验证在不久的将来将不再可用。从%s中删除设置"

+ 0 - 217
packages/app/src/components/InstallerForm.jsx

@@ -1,217 +0,0 @@
-import React from 'react';
-
-import i18next from 'i18next';
-import { useTranslation, i18n } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import { i18n as i18nConfig } from '^/config/next-i18next.config';
-
-import { useCsrfToken } from '~/stores/context';
-
-class InstallerForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isValidUserName: true,
-      isSubmittingDisabled: false,
-    };
-    this.checkUserName = this.checkUserName.bind(this);
-
-    this.submitHandler = this.submitHandler.bind(this);
-  }
-
-  checkUserName(event) {
-    const axios = require('axios').create({
-      headers: {
-        'Content-Type': 'application/json',
-        'X-Requested-With': 'XMLHttpRequest',
-      },
-      responseType: 'json',
-    });
-    axios.get('/_api/v3/check-username', { params: { username: event.target.value } })
-      .then((res) => { return this.setState({ isValidUserName: res.data.valid }) });
-  }
-
-  submitHandler() {
-    if (this.state.isSubmittingDisabled) {
-      return;
-    }
-
-    this.setState({ isSubmittingDisabled: true });
-    setTimeout(() => {
-      this.setState({ isSubmittingDisabled: false });
-    }, 3000);
-  }
-
-  render() {
-    const { t } = this.props;
-    const hasErrorClass = this.state.isValidUserName ? '' : ' has-error';
-    const unavailableUserId = this.state.isValidUserName
-      ? ''
-      : <span><i className="icon-fw icon-ban" />{ this.props.t('installer.unavaliable_user_id') }</span>;
-
-    return (
-      <div data-testid="installerForm" className={`noLogin-dialog p-3 mx-auto${hasErrorClass}`}>
-        <div className="row">
-          <div className="col-md-12">
-            <p className="alert alert-success">
-              <strong>{ this.props.t('installer.create_initial_account') }</strong><br />
-              <small>{ this.props.t('installer.initial_account_will_be_administrator_automatically') }</small>
-            </p>
-          </div>
-        </div>
-        <div className="row">
-          <form role="form" action="/installer" method="post" id="register-form" className="col-md-12" onSubmit={this.submitHandler}>
-            <div className="dropdown mb-3">
-              <div className="d-flex dropdown-with-icon">
-                <i className="icon-bubbles border-0 rounded-0" />
-                <button
-                  type="button"
-                  className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
-                  id="dropdownLanguage"
-                  data-testid="dropdownLanguage"
-                  data-toggle="dropdown"
-                  aria-haspopup="true"
-                  aria-expanded="true"
-                >
-                  <span className="float-left">
-                    {t('meta.display_name')}
-                  </span>
-                </button>
-                <input
-                  type="hidden"
-                  name="registerForm[app:globalLang]"
-                />
-                <div className="dropdown-menu" aria-labelledby="dropdownLanguage">
-                  {
-                    i18nConfig.locales.map((locale) => {
-                      const fixedT = i18n.getFixedT(locale);
-                      i18n.loadLanguages(i18nConfig.locales);
-
-                      return (
-                        <button
-                          key={locale}
-                          data-testid={`dropdownLanguageMenu-${locale}`}
-                          className="dropdown-item"
-                          type="button"
-                          onClick={() => { i18next.changeLanguage(locale) }}
-                        >
-                          {fixedT('meta.display_name')}
-                        </button>
-                      );
-                    })
-                  }
-                </div>
-              </div>
-            </div>
-
-            <div className={`input-group mb-3${hasErrorClass}`}>
-              <div className="input-group-prepend">
-                <span className="input-group-text"><i className="icon-user" /></span>
-              </div>
-              <input
-                data-testid="tiUsername"
-                type="text"
-                className="form-control"
-                placeholder={this.props.t('User ID')}
-                name="registerForm[username]"
-                defaultValue={this.props.userName}
-                // onBlur={this.checkUserName} // need not to check username before installation -- 2020.07.24 Yuki Takei
-                required
-              />
-            </div>
-            <p className="form-text">{ unavailableUserId }</p>
-
-            <div className="input-group mb-3">
-              <div className="input-group-prepend">
-                <span className="input-group-text"><i className="icon-tag" /></span>
-              </div>
-              <input
-                data-testid="tiName"
-                type="text"
-                className="form-control"
-                placeholder={this.props.t('Name')}
-                name="registerForm[name]"
-                defaultValue={this.props.name}
-                required
-              />
-            </div>
-
-            <div className="input-group mb-3">
-              <div className="input-group-prepend">
-                <span className="input-group-text"><i className="icon-envelope" /></span>
-              </div>
-              <input
-                data-testid="tiEmail"
-                type="email"
-                className="form-control"
-                placeholder={this.props.t('Email')}
-                name="registerForm[email]"
-                defaultValue={this.props.email}
-                required
-              />
-            </div>
-
-            <div className="input-group mb-3">
-              <div className="input-group-prepend">
-                <span className="input-group-text"><i className="icon-lock" /></span>
-              </div>
-              <input
-                data-testid="tiPassword"
-                type="password"
-                className="form-control"
-                placeholder={this.props.t('Password')}
-                name="registerForm[password]"
-                required
-              />
-            </div>
-
-            <input type="hidden" name="_csrf" value={this.props.csrfToken} />
-
-            <div className="input-group mt-4 mb-3 d-flex justify-content-center">
-              <button
-                data-testid="btnSubmit"
-                type="submit"
-                className="btn-fill btn btn-register"
-                id="register"
-                disabled={this.state.isSubmittingDisabled}
-              >
-                <div className="eff"></div>
-                <span className="btn-label"><i className="icon-user-follow" /></span>
-                <span className="btn-label-text">{ this.props.t('Create') }</span>
-              </button>
-            </div>
-
-            <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>
-            </div>
-          </form>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-InstallerForm.propTypes = {
-  // i18next
-  t: PropTypes.func.isRequired,
-  // for input value
-  userName: PropTypes.string,
-  name: PropTypes.string,
-  email: PropTypes.string,
-  csrfToken: PropTypes.string,
-};
-
-const InstallerFormWrapperFC = (props) => {
-  const { t } = useTranslation();
-  const { data: csrfToken } = useCsrfToken();
-
-  return <InstallerForm t={t} csrfToken={csrfToken} {...props} />;
-};
-
-export default InstallerFormWrapperFC;

+ 231 - 0
packages/app/src/components/InstallerForm.tsx

@@ -0,0 +1,231 @@
+import {
+  FormEventHandler, memo, useCallback, useState,
+} from 'react';
+
+import i18next from 'i18next';
+import { useTranslation, i18n } from 'next-i18next';
+
+import { i18n as i18nConfig } from '^/config/next-i18next.config';
+
+import { toastError } from '~/client/util/apiNotification';
+import { apiv3Post } from '~/client/util/apiv3-client';
+
+const InstallerForm = memo((): JSX.Element => {
+  const { t } = useTranslation();
+
+  const [isValidUserName, setValidUserName] = useState(true);
+  const [isSubmittingDisabled, setSubmittingDisabled] = useState(false);
+
+  const checkUserName = useCallback(async(event) => {
+    const axios = require('axios').create({
+      headers: {
+        'Content-Type': 'application/json',
+        'X-Requested-With': 'XMLHttpRequest',
+      },
+      responseType: 'json',
+    });
+    const res = await axios.get('/_api/v3/check-username', { params: { username: event.target.value } });
+    setValidUserName(res.data.valid);
+  }, []);
+
+  const submitHandler: FormEventHandler = useCallback(async(e: any) => {
+    e.preventDefault();
+
+    if (isSubmittingDisabled) {
+      return;
+    }
+
+    setSubmittingDisabled(true);
+    setTimeout(() => {
+      setSubmittingDisabled(false);
+    }, 3000);
+
+    if (e.target.elements == null) {
+      return;
+    }
+
+    const formData = e.target.elements;
+
+    const {
+      'registerForm[username]': { value: username },
+      'registerForm[name]': { value: name },
+      'registerForm[email]': { value: email },
+      'registerForm[password]': { value: password },
+    } = formData;
+
+    const data = {
+      registerForm: {
+        username,
+        name,
+        email,
+        password,
+        'app:globalLang': formData['registerForm[app:globalLang]'].value,
+      },
+    };
+
+    try {
+      await apiv3Post('/installer', data);
+      window.location.href = '/';
+    }
+    catch (errs) {
+      const err = errs[0];
+      const code = err.code;
+
+      if (code === 'failed_to_login_after_install') {
+        toastError(t('installer.failed_to_login_after_install'));
+        setTimeout(() => { window.location.href = '/login' }, 700); // Wait 700 ms to show toastr
+      }
+
+      toastError(t('installer.failed_to_install'));
+    }
+  }, [isSubmittingDisabled, t]);
+
+  const hasErrorClass = isValidUserName ? '' : ' has-error';
+  const unavailableUserId = isValidUserName
+    ? ''
+    : <span><i className="icon-fw icon-ban" />{ t('installer.unavaliable_user_id') }</span>;
+
+  return (
+    <div data-testid="installerForm" className={`noLogin-dialog p-3 mx-auto${hasErrorClass}`}>
+      <div className="row">
+        <div className="col-md-12">
+          <p className="alert alert-success">
+            <strong>{ t('installer.create_initial_account') }</strong><br />
+            <small>{ t('installer.initial_account_will_be_administrator_automatically') }</small>
+          </p>
+        </div>
+      </div>
+      <div className="row">
+        <form role="form" id="register-form" className="col-md-12" onSubmit={submitHandler}>
+          <div className="dropdown mb-3">
+            <div className="d-flex dropdown-with-icon">
+              <i className="icon-bubbles border-0 rounded-0" />
+              <button
+                type="button"
+                className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
+                id="dropdownLanguage"
+                data-testid="dropdownLanguage"
+                data-toggle="dropdown"
+                aria-haspopup="true"
+                aria-expanded="true"
+              >
+                <span className="float-left">
+                  {t('meta.display_name')}
+                </span>
+              </button>
+              <input
+                type="hidden"
+                name="registerForm[app:globalLang]"
+              />
+              <div className="dropdown-menu" aria-labelledby="dropdownLanguage">
+                {
+                  i18nConfig.locales.map((locale) => {
+                    let fixedT;
+                    if (i18n != null) {
+                      fixedT = i18n.getFixedT(locale);
+                      i18n.loadLanguages(i18nConfig.locales);
+                    }
+
+                    return (
+                      <button
+                        key={locale}
+                        data-testid={`dropdownLanguageMenu-${locale}`}
+                        className="dropdown-item"
+                        type="button"
+                        onClick={() => { i18next.changeLanguage(locale) }}
+                      >
+                        {fixedT?.('meta.display_name')}
+                      </button>
+                    );
+                  })
+                }
+              </div>
+            </div>
+          </div>
+
+          <div className={`input-group mb-3${hasErrorClass}`}>
+            <div className="input-group-prepend">
+              <span className="input-group-text"><i className="icon-user" /></span>
+            </div>
+            <input
+              data-testid="tiUsername"
+              type="text"
+              className="form-control"
+              placeholder={t('User ID')}
+              name="registerForm[username]"
+              // onBlur={checkUserName} // need not to check username before installation -- 2020.07.24 Yuki Takei
+              required
+            />
+          </div>
+          <p className="form-text">{ unavailableUserId }</p>
+
+          <div className="input-group mb-3">
+            <div className="input-group-prepend">
+              <span className="input-group-text"><i className="icon-tag" /></span>
+            </div>
+            <input
+              data-testid="tiName"
+              type="text"
+              className="form-control"
+              placeholder={t('Name')}
+              name="registerForm[name]"
+              required
+            />
+          </div>
+
+          <div className="input-group mb-3">
+            <div className="input-group-prepend">
+              <span className="input-group-text"><i className="icon-envelope" /></span>
+            </div>
+            <input
+              data-testid="tiEmail"
+              type="email"
+              className="form-control"
+              placeholder={t('Email')}
+              name="registerForm[email]"
+              required
+            />
+          </div>
+
+          <div className="input-group mb-3">
+            <div className="input-group-prepend">
+              <span className="input-group-text"><i className="icon-lock" /></span>
+            </div>
+            <input
+              data-testid="tiPassword"
+              type="password"
+              className="form-control"
+              placeholder={t('Password')}
+              name="registerForm[password]"
+              required
+            />
+          </div>
+
+          <div className="input-group mt-4 mb-3 d-flex justify-content-center">
+            <button
+              data-testid="btnSubmit"
+              type="submit"
+              className="btn-fill btn btn-register"
+              id="register"
+              disabled={isSubmittingDisabled}
+            >
+              <div className="eff"></div>
+              <span className="btn-label"><i className="icon-user-follow" /></span>
+              <span className="btn-label-text">{ t('Create') }</span>
+            </button>
+          </div>
+
+          <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>
+          </div>
+        </form>
+      </div>
+    </div>
+  );
+});
+
+InstallerForm.displayName = 'InstallerForm';
+
+export default InstallerForm;

+ 7 - 1
packages/app/src/server/routes/apiv3/index.js

@@ -15,7 +15,7 @@ const router = express.Router();
 const routerForAdmin = express.Router();
 const routerForAuth = express.Router();
 
-module.exports = (crowi, app) => {
+module.exports = (crowi, app, isInstalled) => {
 
   // add custom functions to express response
   require('./response')(express, crowi);
@@ -49,6 +49,12 @@ module.exports = (crowi, app) => {
   routerForAuth.post('/register',
     applicationInstalled, registerFormValidator.registerRules(), registerFormValidator.registerValidation, addActivity, login.register);
 
+  // installer
+  if (!isInstalled) {
+    routerForAdmin.use('/installer', require('./installer')(crowi));
+    return [router, routerForAdmin, routerForAuth];
+  }
+
   router.use('/in-app-notification', require('./in-app-notification')(crowi));
 
   router.use('/personal-setting', require('./personal-setting')(crowi));

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

@@ -0,0 +1,76 @@
+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';
+import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+import { registerRules } from '../../middlewares/register-form-validator';
+import { InstallerService, FailedToCreateAdminUserError } from '../../service/installer';
+
+import { ApiV3Response } from './interfaces/apiv3-response';
+
+
+const logger = loggerFactory('growi:routes:apiv3:installer');
+
+
+type FormRequest = Request & { form: any, logIn: any };
+
+module.exports = (crowi: Crowi): Router => {
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const activityEvent = crowi.event('activity');
+
+  const router = express.Router();
+
+  // eslint-disable-next-line max-len
+  router.post('/', registerRules(), apiV3FormValidator, addActivity, async(req: FormRequest, res: ApiV3Response) => {
+    const appService = crowi.appService;
+    if (appService == null) {
+      return res.apiv3Err(new ErrorV3('GROWI cannot be installed due to an internal error', 'app_service_not_setup'), 500);
+    }
+    const registerForm = req.body.registerForm || {};
+
+    const name = registerForm.name;
+    const username = registerForm.username;
+    const email = registerForm.email;
+    const password = registerForm.password;
+    const language = registerForm['app:globalLang'] || 'en_US';
+
+    const installerService = new InstallerService(crowi);
+
+    let adminUser;
+    try {
+      adminUser = await installerService.install({
+        name,
+        username,
+        email,
+        password,
+      }, language);
+    }
+    catch (err) {
+      if (err instanceof FailedToCreateAdminUserError) {
+        return res.apiv3Err(new ErrorV3(err.message, 'failed_to_create_admin_user'));
+      }
+      return res.apiv3Err(new ErrorV3(err, 'failed_to_install'));
+    }
+
+    await appService.setupAfterInstall();
+
+    const parameters = { action: SupportedAction.ACTION_USER_REGISTRATION_SUCCESS };
+    activityEvent.emit('update', res.locals.activity._id, parameters);
+
+    // login with passport
+    req.logIn(adminUser, (err) => {
+      if (err != null) {
+        return res.apiv3Err(new ErrorV3(err, 'failed_to_login_after_install'));
+      }
+
+      return res.apiv3({ message: 'Installation completed (Logged in as an admin user)' });
+    });
+  });
+
+  return router;
+};

+ 1 - 3
packages/app/src/server/routes/index.js

@@ -61,7 +61,7 @@ module.exports = function(crowi, app) {
 
   /* eslint-disable max-len, comma-spacing, no-multi-spaces */
 
-  const [apiV3Router, apiV3AdminRouter, apiV3AuthRouter] = require('./apiv3')(crowi, app);
+  const [apiV3Router, apiV3AdminRouter, apiV3AuthRouter] = require('./apiv3')(crowi, app, isInstalled);
 
   app.use('/api-docs', require('./apiv3/docs')(crowi, app));
 
@@ -92,9 +92,7 @@ module.exports = function(crowi, app) {
 
   // installer
   if (!isInstalled) {
-    const installer = require('./installer')(crowi);
     app.get('/installer'              , applicationNotInstalled, next.delegateToNext);
-    app.post('/installer'             , applicationNotInstalled , registerFormValidator.registerRules(), registerFormValidator.registerValidation, csrfProtection, addActivity, installer.install);
     return;
   }
 

+ 0 - 70
packages/app/src/server/routes/installer.js

@@ -1,70 +0,0 @@
-import { SupportedAction } from '~/interfaces/activity';
-import loggerFactory from '~/utils/logger';
-
-import { InstallerService, FailedToCreateAdminUserError } from '../service/installer';
-
-const logger = loggerFactory('growi:routes:installer');
-
-module.exports = function(crowi) {
-
-  const actions = {};
-
-  const activityEvent = crowi.event('activity');
-
-  actions.index = function(req, res) {
-    return res.render('installer');
-  };
-
-  actions.install = async function(req, res, next) {
-    const registerForm = req.body.registerForm || {};
-
-    if (!req.form.isValid) {
-      return res.render('installer');
-    }
-
-    const name = registerForm.name;
-    const username = registerForm.username;
-    const email = registerForm.email;
-    const password = registerForm.password;
-    const language = registerForm['app:globalLang'] || 'en_US';
-
-    const installerService = new InstallerService(crowi);
-
-    let adminUser;
-    try {
-      adminUser = await installerService.install({
-        name,
-        username,
-        email,
-        password,
-      }, language);
-    }
-    catch (err) {
-      if (err instanceof FailedToCreateAdminUserError) {
-        req.form.errors.push(req.t('message.failed_to_create_admin_user', { errMessage: err.message }));
-      }
-      return res.render('installer');
-    }
-
-    const appService = crowi.appService;
-    appService.setupAfterInstall();
-
-    // login with passport
-    req.logIn(adminUser, (err) => {
-      if (err) {
-        req.flash('successMessage', req.t('message.complete_to_install1'));
-        req.session.redirectTo = '/';
-        return res.redirect('/login');
-      }
-
-      req.flash('successMessage', req.t('message.complete_to_install2'));
-
-      const parameters = { action: SupportedAction.ACTION_USER_REGISTRATION_SUCCESS };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-
-      return res.redirect('/');
-    });
-  };
-
-  return actions;
-};

+ 1 - 1
packages/app/src/server/service/installer.ts

@@ -109,7 +109,7 @@ export class InstallerService {
     return configManager.updateConfigsInTheSameNamespace('crowi', initialConfig, true);
   }
 
-  async install(firstAdminUserToSave: IUser, globalLang: Lang, options?: AutoInstallOptions): Promise<IUser> {
+  async install(firstAdminUserToSave: Pick<IUser, 'name' | 'username' | 'email' | 'password'>, globalLang: Lang, options?: AutoInstallOptions): Promise<IUser> {
     await this.initDB(globalLang, options);
 
     // TODO typescriptize models/user.js and remove eslint-disable-next-line