Merge pull request #793 from mlorb/ml-sci-1498

Adds invite users modal [SCI-1498]
This commit is contained in:
mlorb 2017-09-22 10:01:27 +02:00 committed by GitHub
commit 668a92a3d8
19 changed files with 549 additions and 15 deletions

View file

@ -70,7 +70,10 @@ gem 'paperclip', '~> 5.1' # File attachment, image attachment library
gem 'aws-sdk', '~> 2' gem 'aws-sdk', '~> 2'
gem 'delayed_job_active_record' gem 'delayed_job_active_record'
gem 'devise-async' gem 'devise-async',
git: 'https://github.com/mhfs/devise-async.git',
branch: 'devise-4.x'
gem 'ruby-graphviz', '~> 1.2' # Graphviz for rails gem 'ruby-graphviz', '~> 1.2' # Graphviz for rails
gem 'tinymce-rails', '~> 4.6.4' # Rich text editor gem 'tinymce-rails', '~> 4.6.4' # Rich text editor

View file

@ -21,6 +21,14 @@ GIT
activejob (>= 4.2) activejob (>= 4.2)
paperclip (>= 3.3) paperclip (>= 3.3)
GIT
remote: https://github.com/mhfs/devise-async.git
revision: 177f6363a002f7ff28f1d289c8cab7ad8d9cb8c5
branch: devise-4.x
specs:
devise-async (0.10.2)
devise (>= 4.0)
GIT GIT
remote: https://github.com/phatworx/devise_security_extension.git remote: https://github.com/phatworx/devise_security_extension.git
revision: b2ee978af7d49f0fb0e7271c6ac074dfb4d39353 revision: b2ee978af7d49f0fb0e7271c6ac074dfb4d39353
@ -175,8 +183,6 @@ GEM
railties (>= 4.1.0, < 5.2) railties (>= 4.1.0, < 5.2)
responders responders
warden (~> 1.2.3) warden (~> 1.2.3)
devise-async (0.7.0)
devise (>= 2.2)
devise_invitable (1.7.2) devise_invitable (1.7.2)
actionmailer (>= 4.1.0) actionmailer (>= 4.1.0)
devise (>= 4.0.0) devise (>= 4.0.0)
@ -487,7 +493,7 @@ DEPENDENCIES
delayed_job_active_record delayed_job_active_record
delayed_paperclip! delayed_paperclip!
devise (~> 4.3.0) devise (~> 4.3.0)
devise-async devise-async!
devise_invitable devise_invitable
devise_security_extension! devise_security_extension!
factory_girl_rails factory_girl_rails

View file

@ -0,0 +1,41 @@
module ClientApi
module Users
class InvitationsController < Devise::InvitationsController
before_action :check_invite_users_permission, only: :invite_users
def invite_users
invite_service =
ClientApi::InvitationsService.new(user: current_user,
team: @team,
role: params['user_role'],
emails: params[:emails])
invite_results = invite_service.invitation
success_response(invite_results)
end
def success_response(invite_results)
respond_to do |format|
format.json do
render template: '/client_api/users/invite_users',
status: :ok,
locals: { invite_results: invite_results, team: @team }
end
end
end
private
def check_invite_users_permission
@team = Team.find_by_id(params[:team_id])
if @team && !is_admin_of_team(@team)
respond_to do |format|
format.json do
render json: t('client_api.invite_users.permission_error'),
status: 422
end
end
end
end
end
end
end

View file

@ -0,0 +1,28 @@
import React from "react";
import { func } from "prop-types";
import { FormattedMessage } from "react-intl";
import { DropdownButton, MenuItem } from "react-bootstrap";
const InviteUsersButton = props => (
<DropdownButton
bsStyle={"primary"}
title={<FormattedMessage id="invite_users.dropdown_button.invite" />}
id="invite_users.submit_button"
>
<MenuItem onClick={() => props.handleClick("guest")}>
<FormattedMessage id="invite_users.dropdown_button.guest" />
</MenuItem>
<MenuItem onClick={() => props.handleClick("normal_user")}>
<FormattedMessage id="invite_users.dropdown_button.normal_user" />
</MenuItem>
<MenuItem onClick={() => props.handleClick("admin")}>
<FormattedMessage id="invite_users.dropdown_button.admin" />
</MenuItem>
</DropdownButton>
);
InviteUsersButton.propTypes = {
handleClick: func.isRequired
};
export default InviteUsersButton;

View file

@ -0,0 +1,42 @@
import React from "react";
import { string, func, arrayOf } from "prop-types";
import { FormattedMessage } from "react-intl";
import { FormGroup, HelpBlock } from "react-bootstrap";
import TagsInput from "react-tagsinput";
import { INVITE_USERS_LIMIT } from "../../../config/constants/numeric";
const InviteUsersForm = props => (
<FormGroup controlId="form-invite-user">
<p>
<FormattedMessage
id="invite_users.input_text"
values={{ team: props.teamName }}
/>
</p>
<TagsInput
value={props.tags}
addKeys={[9, 13, 188]}
addOnPaste
onlyUnique
maxTags={INVITE_USERS_LIMIT}
inputProps={{
placeholder: ""
}}
onChange={props.handleChange}
/>
<HelpBlock>
<em>
<FormattedMessage id="invite_users.input_help" />
</em>
</HelpBlock>
</FormGroup>
);
InviteUsersForm.propTypes = {
tags: arrayOf(string.isRequired).isRequired,
handleChange: func.isRequired,
teamName: string.isRequired
};
export default InviteUsersForm;

View file

@ -0,0 +1,38 @@
import React from "react";
import { shape, arrayOf, string } from "prop-types";
import { Alert } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
const InviteUsersResults = props => (
<div>
<h5>
<FormattedMessage id="invite_users.results_title" />
</h5>
<hr />
{props.results.invite_results.map(result => (
<Alert bsStyle={result.alert} key={result.email}>
<strong>{result.email}</strong>
&nbsp;-&nbsp;
<FormattedMessage
id={`invite_users.results_msg.${result.status}`}
values={{
team: props.results.team_name,
role: (
<FormattedMessage id={`invite_users.roles.${result.user_role}`} />
),
nr: result.invite_limit
}}
/>
</Alert>
))}
</div>
);
InviteUsersResults.propTypes = {
results: shape({
invite_results: arrayOf.isRequired,
team_name: string.isRequired
}).isRequired
};
export default InviteUsersResults;

View file

@ -0,0 +1,120 @@
import React, { Component } from "react";
import { bool, func, shape, number, string } from "prop-types";
import { FormattedMessage } from "react-intl";
import { Modal, ButtonToolbar, Button } from "react-bootstrap";
import styled from "styled-components";
import axios from "../../config/axios";
import {
INVITE_USERS_PATH,
TEAM_DETAILS_PATH
} from "../../config/api_endpoints";
import InviteUsersForm from "./components/InviteUsersForm";
import InviteUsersResults from "./components/InviteUsersResults";
import InviteUsersButton from "./components/InviteUsersButton";
const StyledButtonToolbar = styled(ButtonToolbar)`float: right;`;
class InviteUsersModal extends Component {
constructor(props) {
super(props);
this.state = {
showInviteUsersResults: false,
inputTags: [],
inviteResults: []
};
this.handleInputChange = this.handleInputChange.bind(this);
this.inviteAs = this.inviteAs.bind(this);
this.handleCloseModal = this.handleCloseModal.bind(this);
}
handleCloseModal() {
const path = TEAM_DETAILS_PATH.replace(":team_id", this.props.team.id);
this.props.onCloseModal();
this.setState({
showInviteUsersResults: false,
inputTags: [],
inviteResults: []
});
// Update team members table
axios.get(path).then(response => {
const { users } = response.data.team_details;
this.props.updateUsersCallback(users);
});
}
handleInputChange(inputTags) {
this.setState({ inputTags });
}
inviteAs(role) {
axios
.put(INVITE_USERS_PATH, {
user_role: role,
emails: this.state.inputTags,
team_id: this.props.team.id
})
.then(({ data }) => {
this.setState({ inviteResults: data, showInviteUsersResults: true});
})
.catch(error => {
console.log("Invite As Error: ", error);
if (error.response) {
console.log("Error message:", error.response.data);
// TO DO: put this error in flash msg
}
});
}
render() {
let modalBody = null;
let inviteButton = null;
if (this.state.showInviteUsersResults) {
modalBody = <InviteUsersResults results={this.state.inviteResults} />;
inviteButton = null;
} else {
modalBody = (
<InviteUsersForm
tags={this.state.inputTags}
handleChange={this.handleInputChange}
teamName={this.props.team.name}
/>
);
inviteButton = <InviteUsersButton handleClick={this.inviteAs} />;
}
return (
<Modal show={this.props.showModal} onHide={this.handleCloseModal}>
<Modal.Header closeButton>
<Modal.Title>
<FormattedMessage
id="invite_users.modal_title"
values={{ team: this.props.team.name }}
/>
</Modal.Title>
</Modal.Header>
<Modal.Body>{modalBody}</Modal.Body>
<Modal.Footer>
<StyledButtonToolbar>
<Button onClick={this.handleCloseModal}>
<FormattedMessage id="general.cancel" />
</Button>
{inviteButton}
</StyledButtonToolbar>
</Modal.Footer>
</Modal>
);
}
}
InviteUsersModal.propTypes = {
showModal: bool.isRequired,
onCloseModal: func.isRequired,
team: shape({
id: number.isRequired,
name: string.isRequired
}).isRequired,
updateUsersCallback: func.isRequired
};
export default InviteUsersModal;

View file

@ -39,6 +39,7 @@ export const CHANGE_USER_RECENT_NOTIFICATION_EMAIL_PATH =
"/client_api/users/change_recent_notification_email"; "/client_api/users/change_recent_notification_email";
export const CHANGE_USER_SYSTEM_MESSAGE_NOTIFICATION_EMAIL_PATH = export const CHANGE_USER_SYSTEM_MESSAGE_NOTIFICATION_EMAIL_PATH =
"/client_api/users/change_system_notification_email"; "/client_api/users/change_system_notification_email";
export const INVITE_USERS_PATH = "/client_api/users/invite_users";
// info dropdown_title // info dropdown_title
export const CUSTOMER_SUPPORT_LINK = "http://scinote.net/support"; export const CUSTOMER_SUPPORT_LINK = "http://scinote.net/support";

View file

@ -2,3 +2,4 @@ export const ENTER_KEY_CODE = 13;
export const NAME_MIN_LENGTH = 2; export const NAME_MIN_LENGTH = 2;
export const NAME_MAX_LENGTH = 255; export const NAME_MAX_LENGTH = 255;
export const TEXT_MAX_LENGTH = 10000; export const TEXT_MAX_LENGTH = 10000;
export const INVITE_USERS_LIMIT = 20;

View file

@ -21,6 +21,43 @@ export default {
notifications_label: "Notifications", notifications_label: "Notifications",
info_label: "Info" info_label: "Info"
}, },
invite_users: {
modal_title: "Invite users to team {team}",
input_text: "Invite more people to team {team} and start using sciNote.",
input_help:
"Input one or multiple emails, confirm each email with ENTER key.",
dropdown_button: {
invite: "Invite user/s",
guest: "as Guest/s",
normal_user: "as Normal user/s",
admin: "as Administrator/s"
},
results_title: "Invitation results:",
roles: {
guest: "Guest",
normal_user: "Normal user",
admin: "Administrator"
},
results_msg: {
user_exists: "User is already a member of sciNote.",
user_exists_unconfirmed:
"User is already a member of sciNote but is not confirmed yet.",
user_exists_and_in_team_unconfirmed:
"User is already a member of sciNote and team {team} as {role} but is not confirmed yet.",
user_exists_invited_to_team_unconfirmed:
"User is already a member of sciNote but is not confirmed yet - successfully invited to team {team} as {role}.",
user_exists_and_in_team:
"User is already a member of sciNote and team {team} as {role}.",
user_exists_invited_to_team:
"User was already a member of sciNote - successfully invited to team {team} as {role}.",
user_created: "User succesfully invited to sciNote.",
user_created_invited_to_team:
"User successfully invited to sciNote and team {team} as {role}.",
user_invalid: "Invalid email.",
too_many_emails:
"Only invited first {nr} emails. To invite more users, fill in another invitation form."
}
},
settings_page: { settings_page: {
all_teams: "All teams", all_teams: "All teams",
in_team: "You are member of {num} team", in_team: "You are member of {num} team",
@ -64,20 +101,28 @@ export default {
yes: "Yes", yes: "Yes",
leave_team_modal: { leave_team_modal: {
title: "Leave team {teamName}", title: "Leave team {teamName}",
subtitle: "Are you sure you wish to leave team My projects? This action is irreversible.", subtitle:
"Are you sure you wish to leave team My projects? This action is irreversible.",
warnings: "Leaving team has following consequences:", warnings: "Leaving team has following consequences:",
warning_message_one: "you will lose access to all content belonging to the team (including projects, tasks, protocols and activities);", warning_message_one:
warning_message_two: "all projects in the team where you were the sole <b>Owner</b> will receive a new owner from the team administrators;", "you will lose access to all content belonging to the team (including projects, tasks, protocols and activities);",
warning_message_three: "all repository protocols in the team belonging to you will be reassigned onto a new owner from team administrators.", warning_message_two:
"all projects in the team where you were the sole <b>Owner</b> will receive a new owner from the team administrators;",
warning_message_three:
"all repository protocols in the team belonging to you will be reassigned onto a new owner from team administrators.",
leave_team: "Leave" leave_team: "Leave"
}, },
remove_user_modal: { remove_user_modal: {
title: "Remove user {user} from team {team}", title: "Remove user {user} from team {team}",
subtitle: "Are you sure you wish to remove user {user} from team {team}?", subtitle:
"Are you sure you wish to remove user {user} from team {team}?",
warnings: "Removing user from team has following consequences:", warnings: "Removing user from team has following consequences:",
warning_message_one: "user will lose access to all content belonging to the team (including projects, tasks, protocols and activities);", warning_message_one:
warning_message_two: "all projects in the team where user was the sole <b>Owner</b> will be reassigned onto you as a new owner;", "user will lose access to all content belonging to the team (including projects, tasks, protocols and activities);",
warning_message_three: "all repository protocols in the team belonging to user will be reassigned onto you.", warning_message_two:
"all projects in the team where user was the sole <b>Owner</b> will be reassigned onto you as a new owner;",
warning_message_three:
"all repository protocols in the team belonging to user will be reassigned onto you.",
remove_user: "Remove user" remove_user: "Remove user"
}, },
update_team_description_modal: { update_team_description_modal: {

View file

@ -10,6 +10,7 @@ import {
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import axios from "../../../../../config/axios"; import axios from "../../../../../config/axios";
import InviteUsersModal from "../../../../../components/InviteUsersModal";
import RemoveUserModal from "./RemoveUserModal"; import RemoveUserModal from "./RemoveUserModal";
import DataTable from "../../../../../components/data_table"; import DataTable from "../../../../../components/data_table";
import { UPDATE_USER_TEAM_ROLE_PATH } from "../../../../../config/api_endpoints"; import { UPDATE_USER_TEAM_ROLE_PATH } from "../../../../../config/api_endpoints";
@ -23,8 +24,18 @@ const initalUserToRemove = {
class TeamsMembers extends Component { class TeamsMembers extends Component {
constructor(params) { constructor(params) {
super(params); super(params);
this.state = { showModal: false, userToRemove: initalUserToRemove }; this.state = {
showModal: false,
showInviteUsersModal: false,
userToRemove: initalUserToRemove
};
this.memberAction = this.memberAction.bind(this); this.memberAction = this.memberAction.bind(this);
this.showInviteUsersModalCallback = this.showInviteUsersModalCallback.bind(
this
);
this.closeInviteUsersModalCallback = this.closeInviteUsersModalCallback.bind(
this
);
this.hideModal = this.hideModal.bind(this); this.hideModal = this.hideModal.bind(this);
} }
@ -45,6 +56,14 @@ class TeamsMembers extends Component {
.catch(error => console.log(error)); .catch(error => console.log(error));
} }
showInviteUsersModalCallback() {
this.setState({ showInviteUsersModal: true });
}
closeInviteUsersModalCallback() {
this.setState({ showInviteUsersModal: false });
}
hideModal() { hideModal() {
this.setState({ showModal: false, userToRemove: initalUserToRemove }); this.setState({ showModal: false, userToRemove: initalUserToRemove });
} }
@ -169,7 +188,7 @@ class TeamsMembers extends Component {
<FormattedMessage id="settings_page.single_team.members_panel_title" /> <FormattedMessage id="settings_page.single_team.members_panel_title" />
} }
> >
<Button> <Button bsStyle="primary" onClick={this.showInviteUsersModalCallback}>
<Glyphicon glyph="plus" /> <Glyphicon glyph="plus" />
<FormattedMessage id="settings_page.single_team.add_members" /> <FormattedMessage id="settings_page.single_team.add_members" />
</Button> </Button>
@ -181,6 +200,12 @@ class TeamsMembers extends Component {
updateUsersCallback={this.props.updateUsersCallback} updateUsersCallback={this.props.updateUsersCallback}
userToRemove={this.state.userToRemove} userToRemove={this.state.userToRemove}
/> />
<InviteUsersModal
showModal={this.state.showInviteUsersModal}
onCloseModal={this.closeInviteUsersModalCallback}
team={this.props.team}
updateUsersCallback={this.props.updateUsersCallback}
/>
</Panel> </Panel>
); );
} }

View file

@ -1,6 +1,7 @@
@import 'constants'; @import 'constants';
@import 'react-bootstrap-timezone-picker/dist/react-bootstrap-timezone-picker.min.css'; @import 'react-bootstrap-timezone-picker/dist/react-bootstrap-timezone-picker.min.css';
@import '~react-bootstrap-table/dist/react-bootstrap-table.min'; @import '~react-bootstrap-table/dist/react-bootstrap-table.min';
@import 'react-tagsinput/react-tagsinput.css';
body { body {
background-color: $color-concrete; background-color: $color-concrete;

View file

@ -0,0 +1,160 @@
module ClientApi
class InvitationsService
include InputSanitizeHelper
include UsersGenerator
def initialize(args)
@user = args[:user]
@emails = args[:emails].map(&:downcase)
@team = args[:team]
@role = args[:role]
raise ClientApi::CustomInvitationsError unless @team && @role &&
@emails && @emails.present?
if @role && !UserTeam.roles.keys.include?(@role)
raise ClientApi::CustomInvitationsError
end
end
def invitation
invite_results = []
@emails.each_with_index do |email, index|
result = {}
# Check invite users limit
if index >= Constants::INVITE_USERS_LIMIT
result[:status] = :too_many_emails
result[:alert] = :danger
result[:invite_user_limit] = Constants::INVITE_USERS_LIMIT
invite_results << result
break
end
result[:email] = email
# Check if user already exists
user = User.find_by_email(email) if User.exists?(email: email)
# Handle user invitation
result = handle_user(result, email, user)
invite_results << result
end
invite_results
end
private
def handle_user(result, email, user)
return handle_new_user(result, email, user) if user.blank?
handle_existing_user(result, user)
end
def handle_new_user(result, email, user)
password = generate_user_password
# Validate the user data
error = (Constants::BASIC_EMAIL_REGEX !~ email)
error = validate_user(email, email, password).count > 0 unless error
if !error
# Invite new user
user = invite_new_user(email)
result[:status] = :user_created
result[:alert] = :success
result[:user] = user
# Invitation to team
if @team.present?
user_team = create_user_team_relation_and_notification(user)
result[:status] = :user_created_invited_to_team
result[:user_team] = user_team
end
else
# Return invalid status
result[:status] = :user_invalid
result[:alert] = :danger
end
result
end
def handle_existing_user(result, user)
result[:status] =
:"#{:user_exists}#{:_unconfirmed unless user.confirmed?}"
result[:alert] = :info
result[:user] = user
# Invitation to team
if @team.present?
if UserTeam.exists?(user: user, team: @team)
user_team = UserTeam.where(user: user, team: @team).first
end
if user_team.present?
result[:status] =
:"#{:user_exists_and_in_team}#{:_unconfirmed unless user
.confirmed?}"
else
user_team = create_user_team_relation_and_notification(user)
result[:status] =
:"#{:user_exists_invited_to_team}#{:_unconfirmed unless user
.confirmed?}"
end
result[:user_team] = user_team
end
result
end
def invite_new_user(email)
user = User.invite!(
full_name: email,
email: email,
initials: email.upcase[0..1],
skip_invitation: true
)
user.update(invited_by: @user)
# Sending email invitation is done in background job to prevent
# issues with email delivery. Also invite method must be call
# with :skip_invitation attribute set to true - see above.
user.delay.deliver_invitation
user
end
def create_user_team_relation_and_notification(user)
user_team = UserTeam.new(
user: user,
team: @team,
role: @role
)
user_team.save
generate_notification(
@user,
user,
user_team.role_str,
user_team.team
)
user_team
end
def generate_notification(user, target_user, role, team)
title = I18n.t('notifications.assign_user_to_team',
assigned_user: target_user.name,
role: role,
team: team.name,
assigned_by_user: user.name)
message = "#{I18n.t('search.index.team')} #{team.name}"
notification = Notification.create(
type_of: :assignment,
title: sanitize_input(title),
message: sanitize_input(message)
)
if target_user.settings[:notifications][:assignments]
UserNotification.create(notification: notification, user: target_user)
end
end
end
CustomInvitationsError = Class.new(StandardError)
end

View file

@ -0,0 +1,12 @@
json.invite_results invite_results do |invite_result|
json.status invite_result[:status]
json.alert invite_result[:alert]
json.email invite_result[:email]
if invite_result[:user_team].present?
json.user_role invite_result[:user_team].role
end
if invite_result[:invite_user_limit].present?
json.invite_limit invite_result[:invite_user_limit]
end
end
json.team_name team.name if team.present?

View file

@ -83,7 +83,8 @@
</div> </div>
<div class="panel-body"> <div class="panel-body">
<div class="col-xs-24 col-sm-12"> <div class="col-xs-24 col-sm-12">
<a href="#" class="btn btn-primary pull-right row" data-trigger="invite-users" data-modal-id="team-invite-users-modal"> <a href="#" class="btn btn-primary pull-right row" data-trigger="invite-users"
data-turbolinks="false" data-modal-id="team-invite-users-modal">
<span class="glyphicon glyphicon-plus"></span> <span class="glyphicon glyphicon-plus"></span>
<%= t("users.settings.teams.edit.add_user") %> <%= t("users.settings.teams.edit.add_user") %>
</a> </a>

View file

@ -1825,3 +1825,5 @@ en:
user_teams: user_teams:
leave_team_error: "An error occured." leave_team_error: "An error occured."
leave_flash: "Successfuly left team %{team}." leave_flash: "Successfuly left team %{team}."
invite_users:
permission_error: "You don't have permission to invite additional users to team. Contact its administrator/s."

View file

@ -54,6 +54,9 @@ Rails.application.routes.draw do
to: 'users#change_recent_notification_email' to: 'users#change_recent_notification_email'
post '/change_system_notification_email', post '/change_system_notification_email',
to: 'users#change_system_notification_email' to: 'users#change_system_notification_email'
devise_scope :user do
put '/invite_users', to: 'invitations#invite_users'
end
end end
end end

View file

@ -68,6 +68,7 @@
"react-bootstrap-table": "^4.0.0", "react-bootstrap-table": "^4.0.0",
"react-bootstrap-timezone-picker": "^1.0.11", "react-bootstrap-timezone-picker": "^1.0.11",
"react-data-grid": "^2.0.2", "react-data-grid": "^2.0.2",
"react-tagsinput": "^3.17.0",
"react-dom": "^15.6.1", "react-dom": "^15.6.1",
"react-intl": "^2.3.0", "react-intl": "^2.3.0",
"react-intl-redux": "^0.6.0", "react-intl-redux": "^0.6.0",

View file

@ -4815,6 +4815,10 @@ react-s-alert@^1.3.0:
dependencies: dependencies:
babel-runtime "^6.23.0" babel-runtime "^6.23.0"
react-tagsinput@^3.17.0:
version "3.18.0"
resolved "https://registry.yarnpkg.com/react-tagsinput/-/react-tagsinput-3.18.0.tgz#40e036fc0f4c3d6b4689858189ab02926717a818"
react-timezone@^0.2.0: react-timezone@^0.2.0:
version "0.2.0" version "0.2.0"
resolved "https://registry.yarnpkg.com/react-timezone/-/react-timezone-0.2.0.tgz#031b6d700b15eb6dd977e326b05fe9080b8a2b6d" resolved "https://registry.yarnpkg.com/react-timezone/-/react-timezone-0.2.0.tgz#031b6d700b15eb6dd977e326b05fe9080b8a2b6d"