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..7765cf609 --- /dev/null +++ b/app/controllers/client_api/users/invitations_controller.rb @@ -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 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..4b38f0092 --- /dev/null +++ b/app/javascript/src/components/InviteUsersModal/components/InviteUsersButton.jsx @@ -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 => ( + } + id="invite_users.submit_button" + > + props.handleClick("guest")}> + + + props.handleClick("normal_user")}> + + + props.handleClick("admin")}> + + + +); + +InviteUsersButton.propTypes = { + handleClick: 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..3f0805b24 --- /dev/null +++ b/app/javascript/src/components/InviteUsersModal/components/InviteUsersForm.jsx @@ -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 => ( + +

+ +

+ + + + + + +
+); + +InviteUsersForm.propTypes = { + tags: arrayOf(string.isRequired).isRequired, + handleChange: func.isRequired, + teamName: 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..6c27fb14c --- /dev/null +++ b/app/javascript/src/components/InviteUsersModal/components/InviteUsersResults.jsx @@ -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 => ( +
+
+ +
+
+ {props.results.invite_results.map(result => ( + + {result.email} +  -  + + ), + nr: result.invite_limit + }} + /> + + ))} +
+); + +InviteUsersResults.propTypes = { + results: shape({ + invite_results: arrayOf.isRequired, + team_name: string.isRequired + }).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..a6a82a0d2 --- /dev/null +++ b/app/javascript/src/components/InviteUsersModal/index.jsx @@ -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 = ; + inviteButton = null; + } else { + modalBody = ( + + ); + inviteButton = ; + } + + return ( + + + + + + + {modalBody} + + + + {inviteButton} + + + + ); + } +} + +InviteUsersModal.propTypes = { + showModal: bool.isRequired, + onCloseModal: func.isRequired, + team: shape({ + id: number.isRequired, + name: string.isRequired + }).isRequired, + updateUsersCallback: 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..9c066088b 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..07c3295eb 100644 --- a/app/javascript/src/config/locales/messages.js +++ b/app/javascript/src/config/locales/messages.js @@ -21,6 +21,43 @@ export default { notifications_label: "Notifications", 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: { all_teams: "All teams", in_team: "You are member of {num} team", @@ -64,20 +101,28 @@ export default { yes: "Yes", leave_team_modal: { 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:", - warning_message_one: "you will lose access to all content belonging to the team (including projects, tasks, protocols and activities);", - warning_message_two: "all projects in the team where you were the sole Owner 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.", + warning_message_one: + "you will lose access to all content belonging to the team (including projects, tasks, protocols and activities);", + warning_message_two: + "all projects in the team where you were the sole Owner 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" }, remove_user_modal: { 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:", - warning_message_one: "user will lose access to all content belonging to the team (including projects, tasks, protocols and activities);", - warning_message_two: "all projects in the team where user was the sole Owner 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.", + warning_message_one: + "user will lose access to all content belonging to the team (including projects, tasks, protocols and activities);", + warning_message_two: + "all projects in the team where user was the sole Owner 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" }, update_team_description_modal: { 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..8f703018b 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,18 @@ 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 +56,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 +188,7 @@ class TeamsMembers extends Component { } > - @@ -181,6 +200,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..434b3392b 100644 --- a/app/javascript/src/styles/main.scss +++ b/app/javascript/src/styles/main.scss @@ -1,6 +1,7 @@ @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; diff --git a/app/services/client_api/invitations_service.rb b/app/services/client_api/invitations_service.rb new file mode 100644 index 000000000..3c710fbd4 --- /dev/null +++ b/app/services/client_api/invitations_service.rb @@ -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 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..d0c4d71c2 --- /dev/null +++ b/app/views/client_api/users/invite_users.json.jbuilder @@ -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? diff --git a/app/views/users/settings/teams/show.html.erb b/app/views/users/settings/teams/show.html.erb index 9dc61983b..a432bd8ec 100644 --- a/app/views/users/settings/teams/show.html.erb +++ b/app/views/users/settings/teams/show.html.erb @@ -83,7 +83,8 @@
- + <%= t("users.settings.teams.edit.add_user") %> diff --git a/config/locales/en.yml b/config/locales/en.yml index c266d562c..e5112418b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1825,3 +1825,5 @@ en: user_teams: leave_team_error: "An error occured." 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." 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"