diff --git a/Gemfile b/Gemfile index d487bbb7b..14a76b849 100644 --- a/Gemfile +++ b/Gemfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index a5f40ce5a..ec7e69440 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 diff --git a/app/controllers/client_api/users/invitations_controller.rb b/app/controllers/client_api/users/invitations_controller.rb new file mode 100644 index 000000000..27a5fa6a3 --- /dev/null +++ b/app/controllers/client_api/users/invitations_controller.rb @@ -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 \ No newline at end of file diff --git a/app/javascript/src/components/InviteUsersModal/components/InviteUsersButton.jsx b/app/javascript/src/components/InviteUsersModal/components/InviteUsersButton.jsx new file mode 100644 index 000000000..0ddd97ccf --- /dev/null +++ b/app/javascript/src/components/InviteUsersModal/components/InviteUsersButton.jsx @@ -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 => + } + id="invite_users.submit_button" + > + props.handleClick('guest')}> + + + props.handleClick('normal_user')}> + + + props.handleClick('admin')}> + + + ; + +InviteUsersButton.propTypes = { + handleClick: PropTypes.func.isRequired +}; + +export default InviteUsersButton; diff --git a/app/javascript/src/components/InviteUsersModal/components/InviteUsersForm.jsx b/app/javascript/src/components/InviteUsersModal/components/InviteUsersForm.jsx new file mode 100644 index 000000000..6e8576360 --- /dev/null +++ b/app/javascript/src/components/InviteUsersModal/components/InviteUsersForm.jsx @@ -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 => + +

+ +

+ + + + + + +
; + +InviteUsersForm.propTypes = { + tags: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired, + handleChange: PropTypes.func.isRequired, + teamName: PropTypes.string.isRequired +}; + +export default InviteUsersForm; diff --git a/app/javascript/src/components/InviteUsersModal/components/InviteUsersResults.jsx b/app/javascript/src/components/InviteUsersModal/components/InviteUsersResults.jsx new file mode 100644 index 000000000..87d2ff153 --- /dev/null +++ b/app/javascript/src/components/InviteUsersModal/components/InviteUsersResults.jsx @@ -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 => +
+
+ +
+
+ {props.results.invite_results.map(result => + + {result.email} +  -  + , + nr: result.invite_limit + }} /> + + )} +
; + +InviteUsersResults.propTypes = { + results: PropTypes.object.isRequired +}; + +export default InviteUsersResults; + diff --git a/app/javascript/src/components/InviteUsersModal/index.jsx b/app/javascript/src/components/InviteUsersModal/index.jsx new file mode 100644 index 000000000..b4ab3b8be --- /dev/null +++ b/app/javascript/src/components/InviteUsersModal/index.jsx @@ -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 = ; + inviteButton = null; + } else { + modalBody = ; + inviteButton = ; + } + + return ( + + + + + + + + {modalBody} + + + + + {inviteButton} + + + + ); + } +} + +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; diff --git a/app/javascript/src/config/api_endpoints.js b/app/javascript/src/config/api_endpoints.js index ae5c3693e..9e1fa0172 100644 --- a/app/javascript/src/config/api_endpoints.js +++ b/app/javascript/src/config/api_endpoints.js @@ -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"; diff --git a/app/javascript/src/config/constants/numeric.js b/app/javascript/src/config/constants/numeric.js index 601d72a10..1e917b60a 100644 --- a/app/javascript/src/config/constants/numeric.js +++ b/app/javascript/src/config/constants/numeric.js @@ -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; diff --git a/app/javascript/src/config/locales/messages.js b/app/javascript/src/config/locales/messages.js index 798c7bebd..037c2fb8e 100644 --- a/app/javascript/src/config/locales/messages.js +++ b/app/javascript/src/config/locales/messages.js @@ -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.' + } } } }; diff --git a/app/javascript/src/scenes/SettingsPage/scenes/team/components/TeamsMembers.jsx b/app/javascript/src/scenes/SettingsPage/scenes/team/components/TeamsMembers.jsx index 35a027e81..bed7cf04b 100644 --- a/app/javascript/src/scenes/SettingsPage/scenes/team/components/TeamsMembers.jsx +++ b/app/javascript/src/scenes/SettingsPage/scenes/team/components/TeamsMembers.jsx @@ -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 { } > - @@ -181,6 +196,12 @@ class TeamsMembers extends Component { updateUsersCallback={this.props.updateUsersCallback} userToRemove={this.state.userToRemove} /> + ); } diff --git a/app/javascript/src/styles/main.scss b/app/javascript/src/styles/main.scss index d24494f08..5c5a8f1fb 100644 --- a/app/javascript/src/styles/main.scss +++ b/app/javascript/src/styles/main.scss @@ -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 diff --git a/app/services/client_api/invitations_service.rb b/app/services/client_api/invitations_service.rb new file mode 100644 index 000000000..9971eb275 --- /dev/null +++ b/app/services/client_api/invitations_service.rb @@ -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 \ No newline at end of file diff --git a/app/views/client_api/users/invite_users.json.jbuilder b/app/views/client_api/users/invite_users.json.jbuilder new file mode 100644 index 000000000..95da57caf --- /dev/null +++ b/app/views/client_api/users/invite_users.json.jbuilder @@ -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? \ No newline at end of file diff --git a/app/views/users/settings/teams/show.html.erb b/app/views/users/settings/teams/show.html.erb index 9dc61983b..13b420746 100644 --- a/app/views/users/settings/teams/show.html.erb +++ b/app/views/users/settings/teams/show.html.erb @@ -83,7 +83,7 @@
- + <%= t("users.settings.teams.edit.add_user") %> diff --git a/config/routes.rb b/config/routes.rb index 398e374e7..81f0d7304 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/package.json b/package.json index 0371cd7c3..8f15e51df 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index c0ce92ca1..fe7343bfb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"