adds invite users modal

This commit is contained in:
mlorb 2017-09-20 11:31:07 +02:00
parent e078ae4f77
commit 48c8922604
18 changed files with 510 additions and 49 deletions

View file

@ -70,7 +70,10 @@ gem 'paperclip', '~> 5.1' # File attachment, image attachment library
gem 'aws-sdk', '~> 2'
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 'tinymce-rails', '~> 4.6.4' # Rich text editor

View file

@ -21,6 +21,14 @@ GIT
activejob (>= 4.2)
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
remote: https://github.com/phatworx/devise_security_extension.git
revision: b2ee978af7d49f0fb0e7271c6ac074dfb4d39353
@ -175,8 +183,6 @@ GEM
railties (>= 4.1.0, < 5.2)
responders
warden (~> 1.2.3)
devise-async (0.7.0)
devise (>= 2.2)
devise_invitable (1.7.2)
actionmailer (>= 4.1.0)
devise (>= 4.0.0)
@ -487,7 +493,7 @@ DEPENDENCIES
delayed_job_active_record
delayed_paperclip!
devise (~> 4.3.0)
devise-async
devise-async!
devise_invitable
devise_security_extension!
factory_girl_rails

View file

@ -0,0 +1,34 @@
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])
render_403 if @team && !is_admin_of_team(@team)
end
end
end
end

View file

@ -0,0 +1,27 @@
import React from 'react';
import PropTypes 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: PropTypes.func.isRequired
};
export default InviteUsersButton;

View file

@ -0,0 +1,38 @@
import React from 'react';
import PropTypes 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: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired,
handleChange: PropTypes.func.isRequired,
teamName: PropTypes.string.isRequired
};
export default InviteUsersForm;

View file

@ -0,0 +1,30 @@
import React from 'react';
import PropTypes 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: PropTypes.object.isRequired
};
export default InviteUsersResults;

View file

@ -0,0 +1,107 @@
import React, { Component } from 'react';
import PropTypes 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() {
super();
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 });
this.setState({ showInviteUsersResults: true });
})
.catch(error => {});
}
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: PropTypes.bool.isRequired,
onCloseModal: PropTypes.func.isRequired,
team: PropTypes.shape({
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired
}).isRequired,
updateUsersCallback: PropTypes.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";
export const CHANGE_USER_SYSTEM_MESSAGE_NOTIFICATION_EMAIL_PATH =
"/client_api/users/change_system_notification_email";
export const INVITE_USERS_PATH = '/client_api/users/invite_users';
// info dropdown_title
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_MAX_LENGTH = 255;
export const TEXT_MAX_LENGTH = 10000;
export const INVITE_USERS_LIMIT = 20;

View file

@ -1,25 +1,25 @@
export default {
"en-US": {
'en-US': {
general: {
close: "Close",
cancel: "Cancel",
update: "Update",
edit: "Edit",
loading: "Loading ..."
close: 'Close',
cancel: 'Cancel',
update: 'Update',
edit: 'Edit',
loading: 'Loading ...'
},
error_messages: {
text_too_short: "is too short (minimum is {min_length} characters)",
text_too_long: "is too long (maximum is {max_length} characters)"
},
navbar: {
page_title: "sciNote",
home_label: "Home",
protocols_label: "Protocols",
repositories_label: "Repositories",
activities_label: "Activities",
search_label: "Search",
notifications_label: "Notifications",
info_label: "Info"
page_title: 'sciNote',
home_label: 'Home',
protocols_label: 'Protocols',
repositories_label: 'Repositories',
activities_label: 'Activities',
search_label: 'Search',
notifications_label: 'Notifications',
info_label: 'Info'
},
settings_page: {
all_teams: "All teams",
@ -54,8 +54,8 @@ export default {
"Assignment notifications appear whenever you get assigned to a team, project, task.",
recent_changes: "Recent changes",
recent_changes_msg:
"Recent changes notifications appear whenever there is a change on a task you are assigned to.",
system_message: "System message",
'Recent changes notifications appear whenever there is a change on a task you are assigned to.',
system_message: 'System message',
system_message_msg:
"System message notifications are specifically sent by site maintainers to notify all users about a system update.",
show_in_scinote: "Show in sciNote",
@ -114,29 +114,62 @@ export default {
}
},
activities: {
modal_title: "Activities",
no_data: "No Data",
more_activities: "More Activities"
modal_title: 'Activities',
no_data: 'No Data',
more_activities: 'More Activities'
},
global_team_switch: {
new_team: "New team"
new_team: 'New team'
},
notifications: {
dropdown_title: "Notifications",
dropdown_settings_link: "Settings",
dropdown_show_all: "Show all notifications"
dropdown_title: 'Notifications',
dropdown_settings_link: 'Settings',
dropdown_show_all: 'Show all notifications'
},
info_dropdown: {
customer_support: "Customer support",
tutorials: "Tutorials",
release_notes: "Release notes",
premium: "Premium",
contact_us: "Contact us"
customer_support: 'Customer support',
tutorials: 'Tutorials',
release_notes: 'Release notes',
premium: 'Premium',
contact_us: 'Contact us'
},
user_account_dropdown: {
greeting: "Hi, {name}",
settings: "Settings",
log_out: "Log out"
greeting: 'Hi, {name}',
settings: 'Settings',
log_out: 'Log out'
},
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.'
}
}
}
};

View file

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

View file

@ -1,25 +1,26 @@
@import 'constants';
@import 'react-bootstrap-timezone-picker/dist/react-bootstrap-timezone-picker.min.css';
@import '~react-bootstrap-table/dist/react-bootstrap-table.min';
@import 'react-tagsinput/react-tagsinput.css';
body {
background-color: $color-concrete;
color: $color-emperor;
font-family: "Open Sans", Arial, Helvetica, sans-serif;
font-size: 13px;
background-color: $color-concrete;
color: $color-emperor;
font-family: "Open Sans", Arial, Helvetica, sans-serif;
font-size: 13px;
}
.label-primary {
background-color: $color-theme-primary;
background-color: $color-theme-primary;
}
.btn-primary {
background-color: $color-theme-secondary;
border-color: $primary-hover-color;
margin-right: 7px;
&:hover {
background-color: $primary-hover-color;
}
background-color: $color-theme-secondary;
border-color: $primary-hover-color;
margin-right: 7px;
&:hover {
background-color: $primary-hover-color;
}
}
// // fixes issue with dropdown in datatable

View file

@ -0,0 +1,143 @@
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 @emails && @team && @role
@emails && @emails.empty? { raise ClientApi::CustomInvitationsError }
@role && !UserTeam.roles.keys.include?(@role) { raise ClientApi::CustomInvitationsError }
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)
# User does not exist
if user.blank?
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
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
# User exists
else
result[:status] = :"#{:user_exists}#{:_unconfirmed if !user.confirmed?}"
result[:alert] = :info
result[:user] = user
# Invitation to team
if @team.present?
user_team =
UserTeam.where(user: user, team: @team).first if UserTeam.exists?(user: user, team: @team)
if user_team.present?
result[:status] = :"#{:user_exists_and_in_team}#{:_unconfirmed if !user.confirmed?}"
else
user_team = create_user_team_relation_and_notification(user)
result[:status] = :"#{:user_exists_invited_to_team}#{:_unconfirmed if !user.confirmed?}"
end
result[:user_team] = user_team
end
end
invite_results << result
end
invite_results
end
private
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.assignments_notification
UserNotification.create(notification: notification, user: target_user)
end
end
end
CustomInvitationsError = Class.new(StandardError)
end

View file

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

View file

@ -83,7 +83,7 @@
</div>
<div class="panel-body">
<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>
<%= t("users.settings.teams.edit.add_user") %>
</a>

View file

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

View file

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

View file

@ -4815,6 +4815,10 @@ react-s-alert@^1.3.0:
dependencies:
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:
version "0.2.0"
resolved "https://registry.yarnpkg.com/react-timezone/-/react-timezone-0.2.0.tgz#031b6d700b15eb6dd977e326b05fe9080b8a2b6d"