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

Merge pull request #3998 from weseek/feat/mark-the-user

Feat/mark the user
Yuki Takei 4 лет назад
Родитель
Сommit
3e74c5aa0b

+ 5 - 2
resource/locales/en_US/admin/admin.json

@@ -353,7 +353,8 @@
       "valid_email": "Valid email address is required",
       "temporary_password": "The created user has a temporary password",
       "send_new_password": "Please send the new password to the user.",
-      "send_temporary_password": "Be sure to copy the temporary password ON THIS SCREEN and send it to the user.",
+      "send_temporary_password": "If you have not sent an invitation email, copy the temporary password on this screen and contact the inviter.",
+      "send_email": "You can also send or resend the invitation email from the drop-down in the users table.",
       "existing_email": "The following emails already exist",
       "issue": "Issue"
     },
@@ -367,7 +368,9 @@
       "your_own": "You cannot deactivate your own account",
       "remove_admin_access": "Remove admin access",
       "cannot_remove": "You cannot remove yourself from administrator",
-      "give_admin_access": "Give admin access"
+      "give_admin_access": "Give admin access",
+      "send_invitation_email": "Send invitation email",
+      "resend_invitation_email": "Resend invitation email"
     },
     "reset_password": "Reset Password",
     "reset_password_modal": {

+ 5 - 2
resource/locales/ja_JP/admin/admin.json

@@ -351,7 +351,8 @@
       "valid_email": "メールアドレスを入力してください。",
       "temporary_password": "作成したユーザーは仮パスワードが設定されています。",
       "send_new_password": "新規発行したパスワードを、対象ユーザーへ連絡してください。",
-      "send_temporary_password": "招待メールを送っていない場合、この画面で必ず仮パスワードをコピーし、招待者へ連絡してください。",
+      "send_temporary_password": "招待メールを送っていない場合、この画面で仮パスワードをコピーし、招待者へ連絡してください。",
+      "send_email": "ユーザーテーブルのドロップダウンから招待メールの送信または再送信を行うこともできます。",
       "existing_email": "以下のEmailはすでに存在しています。",
       "issue": "発行"
     },
@@ -365,7 +366,9 @@
       "your_own": "自分自身のアカウントを停止することはできません",
       "remove_admin_access": "管理者から外す",
       "cannot_remove": "自分自身を管理者から外すことはできません",
-      "give_admin_access": "管理者にする"
+      "give_admin_access": "管理者にする",
+      "send_invitation_email": "招待メールの送信",
+      "resend_invitation_email": "招待メールの再送信"
     },
     "reset_password": "パスワードのリセット",
     "reset_password_modal": {

+ 5 - 2
resource/locales/zh_CN/admin/admin.json

@@ -360,7 +360,8 @@
 			"invite_thru_email": "发送邀请电子邮件",
 			"temporary_password": "创建的用户具有临时密码",
 			"send_new_password": "请将新密码发送给用户。",
-			"send_temporary_password": "请确保复制此屏幕上的临时密码并将其发送给用户。",
+			"send_temporary_password": "如果你没有发送电子邮件邀请,请复制此屏幕上的临时密码并联系邀请人。",
+      "send_email": "你也可以从用户表中的下拉菜单中发送或重新发送邀请邮件。",
 			"existing_email": "以下电子邮件已存在",
       "issue": "Issue"
 		},
@@ -374,7 +375,9 @@
 			"your_own": "您不能停用自己的帐户",
 			"remove_admin_access": "删除管理员访问权限",
 			"cannot_remove": "您不能从管理员中删除自己",
-			"give_admin_access": "授予管理员访问权限"
+			"give_admin_access": "授予管理员访问权限",
+      "send_invitation_email": "发送邀请邮件",
+      "resend_invitation_email": "重发邀请函"
 		},
 		"reset_password": "重置密码",
 		"reset_password_modal": {

+ 54 - 0
src/client/js/components/Admin/Users/SendInvitationEmailButton.jsx

@@ -0,0 +1,54 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import PropTypes from 'prop-types';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+import AppContainer from '../../../services/AppContainer';
+import AdminUsersContainer from '../../../services/AdminUsersContainer';
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+const SendInvitationEmailButton = (props) => {
+  const {
+    appContainer, user, isInvitationEmailSended, onSuccessfullySentInvitationEmail,
+  } = props;
+  const { t } = useTranslation();
+
+  const textColor = !isInvitationEmailSended ? 'text-danger' : '';
+
+  const onClickSendInvitationEmailButton = async() => {
+    try {
+      const res = await appContainer.apiv3Put('users/send-invitation-email', { id: user._id });
+      const { failedToSendEmail } = res.data;
+      if (failedToSendEmail == null) {
+        const msg = `Email has been sent<br>・${user.email}`;
+        toastSuccess(msg);
+        onSuccessfullySentInvitationEmail();
+      }
+      else {
+        const msg = { message: `email: ${failedToSendEmail.email}<br>reason: ${failedToSendEmail.reason}` };
+        toastError(msg);
+      }
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
+  return (
+    <button className={`dropdown-item ${textColor}`} type="button" onClick={() => { onClickSendInvitationEmailButton() }}>
+      <i className="icon-fw icon-envelope"></i>
+      {isInvitationEmailSended && (<>{t('admin:user_management.user_table.resend_invitation_email')}</>)}
+      {!isInvitationEmailSended && (<>{t('admin:user_management.user_table.send_invitation_email')}</>)}
+    </button>
+  );
+};
+
+const SendInvitationEmailButtonWrapper = withUnstatedContainers(SendInvitationEmailButton, [AppContainer, AdminUsersContainer]);
+
+SendInvitationEmailButton.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  user: PropTypes.object.isRequired,
+  isInvitationEmailSended: PropTypes.bool.isRequired,
+  onSuccessfullySentInvitationEmail: PropTypes.func.isRequired,
+};
+
+export default SendInvitationEmailButtonWrapper;

+ 52 - 5
src/client/js/components/Admin/Users/UserInviteModal.jsx

@@ -9,7 +9,7 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import { toastSuccess, toastError } from '../../../util/apiNotification';
+import { toastSuccess, toastError, toastWarning } from '../../../util/apiNotification';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
@@ -24,6 +24,7 @@ class UserInviteModal extends React.Component {
       emailInputValue: '',
       sendEmail: false,
       invitedEmailList: null,
+      isCreateUserButtonPushed: false,
     };
 
     this.handleSubmit = this.handleSubmit.bind(this);
@@ -41,6 +42,26 @@ class UserInviteModal extends React.Component {
     toastSuccess('Copied Mail and Password');
   }
 
+  showToasterByEmailList(emailList, toast) {
+    let msg = '';
+    emailList.forEach((email) => {
+      msg += `・${email}<br>`;
+    });
+    switch (toast) {
+      case 'success':
+        msg = `User has been created<br>${msg}`;
+        toastSuccess(msg);
+        break;
+      case 'warning':
+        msg = `Existing email<br>${msg}`;
+        toastWarning(msg);
+        break;
+      case 'error':
+        toastError({ message: msg });
+        break;
+    }
+  }
+
   renderModalBody() {
     const { t } = this.props;
 
@@ -80,6 +101,7 @@ class UserInviteModal extends React.Component {
 
   renderModalFooter() {
     const { t, appContainer } = this.props;
+    const { isCreateUserButtonPushed } = this.state;
     const { isMailerSetup } = appContainer.config;
 
     return (
@@ -116,7 +138,7 @@ class UserInviteModal extends React.Component {
             type="button"
             className="btn btn-primary"
             onClick={this.handleSubmit}
-            disabled={!this.validEmail()}
+            disabled={!this.validEmail() || isCreateUserButtonPushed}
           >
             {t('admin:user_management.invite_modal.issue')}
           </button>
@@ -130,8 +152,9 @@ class UserInviteModal extends React.Component {
 
     return (
       <>
-        <label className="mr-3 text-left text-danger" style={{ flex: 1 }}>
-          {t('admin:user_management.invite_modal.send_temporary_password')}
+        <label className="mr-3 text-left" style={{ flex: 1 }}>
+          <text className="text-danger">{t('admin:user_management.invite_modal.send_temporary_password')}</text>
+          <text>{t('admin:user_management.invite_modal.send_email')}</text>
         </label>
         <button
           type="button"
@@ -186,6 +209,10 @@ class UserInviteModal extends React.Component {
 
   async handleSubmit() {
     const { adminUsersContainer } = this.props;
+    // eslint-disable-next-line no-unused-vars
+    const { isCreateUserButtonPushed } = this.state;
+
+    this.setState({ isCreateUserButtonPushed: true });
 
     const array = this.state.emailInputValue.split('\n');
     const emailList = array.filter((element) => { return element.match(/.+@.+\..+/) });
@@ -195,11 +222,31 @@ class UserInviteModal extends React.Component {
       const emailList = await adminUsersContainer.createUserInvited(shapedEmailList, this.state.sendEmail);
       this.setState({ emailInputValue: '' });
       this.setState({ invitedEmailList: emailList });
-      toastSuccess('Inviting user success');
+
+      if (emailList.createdUserList.length > 0) {
+        const createdEmailList = emailList.createdUserList.map((user) => { return user.email });
+        this.showToasterByEmailList(createdEmailList, 'success');
+      }
+      if (emailList.existingEmailList.length > 0) {
+        this.showToasterByEmailList(emailList.existingEmailList, 'warning');
+      }
+      if (emailList.failedEmailList.length > 0) {
+        const failedEmailList = emailList.failedEmailList.map((failed, index) => {
+          let messgage = `email: ${failed.email}<br>・reason: ${failed.reason}`;
+          if (index !== emailList.failedEmailList.length - 1) {
+            messgage += '<br>';
+          }
+          return messgage;
+        });
+        this.showToasterByEmailList(failedEmailList, 'error');
+      }
     }
     catch (err) {
       toastError(err);
     }
+    finally {
+      this.setState({ isCreateUserButtonPushed: false });
+    }
   }
 
   handleInput(event) {

+ 29 - 15
src/client/js/components/Admin/Users/UserMenu.jsx

@@ -7,9 +7,10 @@ import {
 
 import StatusActivateButton from './StatusActivateButton';
 import StatusSuspendedButton from './StatusSuspendedButton';
-import RemoveUserButton from './UserRemoveButton';
+import UserRemoveButton from './UserRemoveButton';
 import RemoveAdminButton from './RemoveAdminButton';
 import GiveAdminButton from './GiveAdminButton';
+import SendInvitationEmailButton from './SendInvitationEmailButton';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
@@ -21,16 +22,21 @@ class UserMenu extends React.Component {
     super(props);
 
     this.state = {
-
+      isInvitationEmailSended: this.props.user.isInvitationEmailSended,
     };
 
     this.onPasswordResetClicked = this.onPasswordResetClicked.bind(this);
+    this.onSuccessfullySentInvitationEmail = this.onSuccessfullySentInvitationEmail.bind(this);
   }
 
   onPasswordResetClicked() {
     this.props.adminUsersContainer.showPasswordResetModal(this.props.user);
   }
 
+  onSuccessfullySentInvitationEmail() {
+    this.setState({ isInvitationEmailSended: true });
+  }
+
   renderEditMenu() {
     const { t } = this.props;
 
@@ -49,6 +55,7 @@ class UserMenu extends React.Component {
 
   renderStatusMenu() {
     const { t, user } = this.props;
+    const { isInvitationEmailSended } = this.state;
 
     return (
       <Fragment>
@@ -57,7 +64,14 @@ class UserMenu extends React.Component {
         <li>
           {(user.status === 1 || user.status === 3) && <StatusActivateButton user={user} />}
           {user.status === 2 && <StatusSuspendedButton user={user} />}
-          {(user.status === 1 || user.status === 3 || user.status === 5) && <RemoveUserButton user={user} />}
+          {user.status === 5 && (
+          <SendInvitationEmailButton
+            user={user}
+            isInvitationEmailSended={isInvitationEmailSended}
+            onSuccessfullySentInvitationEmail={this.onSuccessfullySentInvitationEmail}
+          />
+          )}
+          {(user.status === 1 || user.status === 3 || user.status === 5) && <UserRemoveButton user={user} />}
         </li>
       </Fragment>
     );
@@ -80,20 +94,20 @@ class UserMenu extends React.Component {
 
   render() {
     const { user } = this.props;
+    const { isInvitationEmailSended } = this.state;
 
     return (
-      <Fragment>
-        <UncontrolledDropdown id="userMenu" size="sm">
-          <DropdownToggle caret color="secondary" outline>
-            <i className="icon-settings"></i>
-          </DropdownToggle>
-          <DropdownMenu positionFixed>
-            {this.renderEditMenu()}
-            {user.status !== 4 && this.renderStatusMenu()}
-            {user.status === 2 && this.renderAdminMenu()}
-          </DropdownMenu>
-        </UncontrolledDropdown>
-      </Fragment>
+      <UncontrolledDropdown id="userMenu" size="sm">
+        <DropdownToggle caret color="secondary" outline>
+          <i className="icon-settings" />
+          {(user.status === 5 && !isInvitationEmailSended) && <i className="fa fa-circle text-danger grw-usermenu-notification-icon" />}
+        </DropdownToggle>
+        <DropdownMenu positionFixed>
+          {this.renderEditMenu()}
+          {user.status !== 4 && this.renderStatusMenu()}
+          {user.status === 2 && this.renderAdminMenu()}
+        </DropdownMenu>
+      </UncontrolledDropdown>
     );
   }
 

+ 1 - 2
src/client/js/services/AdminUsersContainer.js

@@ -164,8 +164,7 @@ export default class AdminUsersContainer extends Container {
       sendEmail,
     });
     await this.retrieveUsersByPagingNum(this.state.activePage);
-    const { invitedUserList } = response.data;
-    return invitedUserList;
+    return response.data;
   }
 
   /**

+ 12 - 0
src/client/js/util/apiNotification.js

@@ -20,6 +20,14 @@ const toastrOption = {
     hideDuration: '100',
     timeOut: '3000',
   },
+  warning: {
+    closeButton: true,
+    progressBar: true,
+    newestOnTop: false,
+    showDuration: '100',
+    hideDuration: '100',
+    timeOut: '6000',
+  },
 };
 
 // accepts both a single error and an array of errors
@@ -35,3 +43,7 @@ export const toastError = (err, header = 'Error', option = toastrOption.error) =
 export const toastSuccess = (body, header = 'Success', option = toastrOption.success) => {
   toastr.success(body, header, option);
 };
+
+export const toastWarning = (body, header = 'Warning', option = toastrOption.warning) => {
+  toastr.warning(body, header, option);
+};

+ 6 - 0
src/client/styles/scss/_user.scss

@@ -34,6 +34,12 @@ $easeInOutCubic: cubic-bezier(0.65, 0, 0.35, 1);
   }
 }
 
+.grw-usermenu-notification-icon {
+  position: absolute;
+  top: -4px;
+  left: 30px;
+}
+
 .draft-list-item {
   .icon-container {
     .icon-copy,

+ 27 - 45
src/server/models/user.js

@@ -4,7 +4,6 @@ const debug = require('debug')('growi:models:user');
 const logger = require('@alias/logger')('growi:models:user');
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
-const path = require('path');
 const uniqueValidator = require('mongoose-unique-validator');
 const md5 = require('md5');
 
@@ -64,6 +63,7 @@ module.exports = function(crowi) {
     createdAt: { type: Date, default: Date.now },
     lastLoginAt: { type: Date },
     admin: { type: Boolean, default: 0, index: true },
+    isInvitationEmailSended: { type: Boolean, default: false },
   }, {
     toObject: {
       transform: (doc, ret, opt) => {
@@ -566,57 +566,24 @@ module.exports = function(crowi) {
     const creationEmailList = emailList.filter((email) => { return existingEmailList.indexOf(email) === -1 });
 
     const createdUserList = [];
-    await Promise.all(creationEmailList.map(async(email) => {
-      const createdEmail = await this.createUserByEmail(email);
-      createdUserList.push(createdEmail);
-    }));
-
-    return { existingEmailList, createdUserList };
-  };
-
-  userSchema.statics.sendEmailbyUserList = async function(userList) {
-    const { appService, mailService } = crowi;
-    const appTitle = appService.getAppTitle();
-
-    await Promise.all(userList.map(async(user) => {
-      if (user.password == null) {
-        return;
-      }
+    const failedToCreateUserEmailList = [];
 
+    for (const email of creationEmailList) {
       try {
-        return mailService.send({
-          to: user.email,
-          subject: `Invitation to ${appTitle}`,
-          template: path.join(crowi.localeDir, 'en_US/admin/userInvitation.txt'),
-          vars: {
-            email: user.email,
-            password: user.password,
-            url: crowi.appService.getSiteUrl(),
-            appTitle,
-          },
-        });
+        // eslint-disable-next-line no-await-in-loop
+        const createdUser = await this.createUserByEmail(email);
+        createdUserList.push(createdUser);
       }
       catch (err) {
-        return debug('fail to send email: ', err);
+        logger.error(err);
+        failedToCreateUserEmailList.push({
+          email,
+          reason: err.message,
+        });
       }
-    }));
-
-  };
-
-  userSchema.statics.createUsersByInvitation = async function(emailList, toSendEmail) {
-    validateCrowi();
-
-    if (!Array.isArray(emailList)) {
-      debug('emailList is not array');
     }
 
-    const afterWorkEmailList = await this.createUsersByEmailList(emailList);
-
-    if (toSendEmail) {
-      await this.sendEmailbyUserList(afterWorkEmailList.createdUserList);
-    }
-
-    return afterWorkEmailList;
+    return { createdUserList, existingEmailList, failedToCreateUserEmailList };
   };
 
   userSchema.statics.createUserByEmailAndPasswordAndStatus = async function(name, username, email, password, lang, status, callback) {
@@ -709,6 +676,21 @@ module.exports = function(crowi) {
     return username;
   };
 
+  userSchema.statics.updateIsInvitationEmailSended = async function(id) {
+    const user = await this.findById(id);
+
+    if (user == null) {
+      throw new Error('User not found');
+    }
+
+    if (user.status !== 5) {
+      throw new Error('The status of the user is not "invited"');
+    }
+
+    user.isInvitationEmailSended = true;
+    user.save();
+  };
+
   class UserUpperLimitException {
 
     constructor() {

+ 111 - 6
src/server/routes/apiv3/users.js

@@ -6,6 +6,8 @@ const express = require('express');
 
 const router = express.Router();
 
+const path = require('path');
+
 const { body, query } = require('express-validator');
 const { isEmail } = require('validator');
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
@@ -116,6 +118,40 @@ module.exports = (crowi) => {
     query('limit').if(value => value != null).isInt({ max: 300 }).withMessage('You should set less than 300 or not to set limit.'),
   ];
 
+  const sendEmailByUserList = async(userList) => {
+    const { appService, mailService } = crowi;
+    const appTitle = appService.getAppTitle();
+    const failedToSendEmailList = [];
+
+    for (const user of userList) {
+      try {
+        // eslint-disable-next-line no-await-in-loop
+        await mailService.send({
+          to: user.email,
+          subject: `Invitation to ${appTitle}`,
+          template: path.join(crowi.localeDir, 'en_US/admin/userInvitation.txt'),
+          vars: {
+            email: user.email,
+            password: user.password,
+            url: crowi.appService.getSiteUrl(),
+            appTitle,
+          },
+        });
+        // eslint-disable-next-line no-await-in-loop
+        await User.updateIsInvitationEmailSended(user.user.id);
+      }
+      catch (err) {
+        logger.error(err);
+        failedToSendEmailList.push({
+          email: user.email,
+          reason: err.message,
+        });
+      }
+    }
+
+    return { failedToSendEmailList };
+  };
+
   /**
    * @swagger
    *
@@ -355,16 +391,35 @@ module.exports = (crowi) => {
    *                    existingEmailList:
    *                      type: object
    *                      description: Users email that already exists
+   *                    failedEmailList:
+   *                      type: object
+   *                      description: Users email that failed to create or send email
    */
   router.post('/invite', loginRequiredStrictly, adminRequired, csrf, validator.inviteEmail, apiV3FormValidator, async(req, res) => {
-    try {
-      const invitedUserList = await User.createUsersByInvitation(req.body.shapedEmailList, req.body.sendEmail);
-      return res.apiv3({ invitedUserList }, 201);
+
+    // Delete duplicate email addresses
+    const emailList = Array.from(new Set(req.body.shapedEmailList));
+    let failedEmailList = [];
+
+    // Create users
+    const createUser = await User.createUsersByEmailList(emailList);
+    if (createUser.failedToCreateUserEmailList.length > 0) {
+      failedEmailList = failedEmailList.concat(createUser.failedToCreateUserEmailList);
     }
-    catch (err) {
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(err));
+
+    // Send email
+    if (req.body.sendEmail) {
+      const sendEmail = await sendEmailByUserList(createUser.createdUserList);
+      if (sendEmail.failedToSendEmailList.length > 0) {
+        failedEmailList = failedEmailList.concat(sendEmail.failedToSendEmailList);
+      }
     }
+
+    return res.apiv3({
+      createdUserList: createUser.createdUserList,
+      existingEmailList: createUser.existingEmailList,
+      failedEmailList,
+    }, 201);
   });
 
   /**
@@ -761,5 +816,55 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /users/send-invitation-email:
+   *      put:
+   *        tags: [Users]
+   *        operationId: sendInvitationEmail
+   *        summary: /users/send-invitation-email
+   *        description: send invitation email
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  id:
+   *                    type: string
+   *                    description: user id for send invitation email
+   *        responses:
+   *          200:
+   *            description: success send invitation email
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    failedToSendEmail:
+   *                      type: object
+   *                      description: email and reasons for email sending failure
+   */
+  router.put('/send-invitation-email', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+    const { id } = req.body;
+
+    try {
+      const user = await User.findById(id);
+      const newPassword = await User.resetPasswordByRandomString(id);
+      const userList = [{
+        email: user.email,
+        password: newPassword,
+        user: { id },
+      }];
+      const sendEmail = await sendEmailByUserList(userList);
+      // return null if absent
+      return res.apiv3({ failedToSendEmail: sendEmail.failedToSendEmailList[0] });
+    }
+    catch (err) {
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(err));
+    }
+  });
+
   return router;
 };