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"
+ >
+
+
+
+ ;
+
+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 {
}
>
-