diff --git a/app/assets/javascripts/sitewide/invite_users_modal.js.erb b/app/assets/javascripts/sitewide/invite_users_modal.js.erb new file mode 100644 index 000000000..eb9b5b5ff --- /dev/null +++ b/app/assets/javascripts/sitewide/invite_users_modal.js.erb @@ -0,0 +1,189 @@ +(function() { + 'use strict'; + + function initializeModal(modal) { + var modalDialog = modal.find('.modal-dialog'); + var type = modal.attr('data-type'); + var stepForm = modal.find('[data-role=step-form]'); + var stepResults = modal.find('[data-role=step-results]'); + var stepResultsDiv = modal.find('[data-role=step-results][data-clear]'); + var inviteBtn = modal.find('[data-role=invite-btn]'); + var inviteWithRoleDiv = + modal.find('[data-role=invite-with-role-div]'); + var inviteWithRoleBtn = + modal.find('[data-role=invite-with-role-btn]'); + var orgSelectorCheckbox = + modal.find('[data-role=org-selector-checkbox]'); + var orgSelectorDropdown = + modal.find('[data-role=org-selector-dropdown]'); + var orgSelectorDropdown2 = $(); + var tagsInput = modal.find('[data-role=tags-input]'); + + // Set max tags + tagsInput.tagsinput({ + maxTags: <%= Constants::INVITE_USERS_LIMIT %> + }); + + modal + .on('show.bs.modal', function() { + // This cannot be scoped outside this function + // because it is generated via JS + orgSelectorDropdown2 = + orgSelectorDropdown + .next('.btn-group.bootstrap-select.form-control') + .find('button.dropdown-toggle, li'); + + // Show/hide correct step + stepForm.show(); + stepResults.hide(); + + // Show/hide buttons & other elements + switch (type) { + case 'invite_to_org_with_role': + case 'invite': + case 'invite_with_org_selector': + case 'invite_with_org_selector_and_role': + inviteBtn.show(); + inviteWithRoleDiv.hide(); + break; + case 'invite_to_org': + inviteBtn.hide(); + inviteWithRoleDiv.show(); + break; + default: + break; + } + + // Checkbox toggle event + if ( + type === 'invite_with_org_selector' || + type === 'invite_with_org_selector_and_role' + ) { + orgSelectorCheckbox.on('change', function() { + if ($(this).is(':checked')) { + orgSelectorDropdown.removeAttr('disabled'); + orgSelectorDropdown2.removeClass('disabled'); + if (type === 'invite_with_org_selector') { + inviteBtn.hide(); + inviteWithRoleDiv.show(); + } + } else { + orgSelectorDropdown.attr('disabled', 'disabled'); + orgSelectorDropdown2.addClass('disabled'); + if (type === 'invite_with_org_selector') { + inviteBtn.show(); + inviteWithRoleDiv.hide(); + } + } + }); + } + + // Toggle depending on input tags + tagsInput + .on('itemAdded', function(event) { + inviteBtn.removeAttr('disabled'); + inviteWithRoleBtn.removeAttr('disabled'); + }) + .on('itemRemoved', function(event) { + if ($(this).val() === null) { + inviteBtn.attr('disabled', 'disabled'); + inviteWithRoleBtn.attr('disabled', 'disabled'); + } + }); + + // Click action + modal.find('[data-action=invite]').on('click', function() { + animateSpinner(modalDialog); + + var data = { + emails: tagsInput.val() + }; + switch (type) { + case 'invite_to_org': + data.organizationId = modal.attr('data-organization-id'); + data.role = $(this).attr('data-organization-role'); + break; + case 'invite_to_org_with_role': + data.organizationId = modal.attr('data-organization-id'); + data.role = modal.attr('data-organization-role'); + break; + case 'invite': + break; + case 'invite_with_org_selector': + if (orgSelectorCheckbox.is(':checked')) { + data.organizationId = orgSelectorDropdown.val(); + data.role = $(this).attr('data-organization-role'); + } + break; + case 'invite_with_org_selector_and_role': + if (orgSelectorCheckbox.is(':checked')) { + data.organizationId = orgSelectorDropdown.val(); + data.role = modal.attr('data-organization-role'); + } + break; + default: + break; + } + + $.ajax({ + method: 'POST', + url: modal.attr('data-url'), + dataType: 'json', + data: data, + success: function(data) { + animateSpinner(modalDialog, false); + stepForm.hide(); + stepResultsDiv.html(data.html); + stepResults.show(); + // Add 'data-invited="true"' status to modal element + modal.attr('data-invited', 'true'); + }, + error: function() { + animateSpinner(modalDialog, false); + modal.modal('hide'); + alert('Error inviting users.'); + } + }); + }); + }) + .on('shown.bs.modal', function() { + tagsInput.tagsinput('focus'); + + // Remove 'data-invited="true"' status + modal.removeAttr('data-invited'); + }) + .on('hide.bs.modal', function() { + // 'Reset' modal state + tagsInput.tagsinput('removeAll'); + orgSelectorCheckbox.prop('checked', false); + inviteBtn.attr('disabled', 'disabled'); + inviteWithRoleBtn.attr('disabled', 'disabled'); + orgSelectorDropdown2.addClass('disabled'); + animateSpinner(modalDialog, false); + + // Unbind event listeners + orgSelectorCheckbox.off('change'); + tagsInput.off('itemAdded itemRemoved'); + + // Hide contents of the results
+ stepResultsDiv.html(''); + stepResults.hide(); + stepForm.show(); + }); + } + + function initializeModalsToggle() { + $("[data-trigger='invite-users']").on('click', function() { + var id = $(this).attr('data-modal-id'); + $('[data-role=invite-users-modal][data-id=' + id + ']') + .modal('show'); + }); + } + + $(document).ready(function() { + $('[data-role=invite-users-modal]').each(function() { + initializeModal($(this)); + }); + initializeModalsToggle(); + }); +})(); diff --git a/app/assets/javascripts/users/settings/organization.js b/app/assets/javascripts/users/settings/organization.js index 8c490b43e..7f1f69908 100644 --- a/app/assets/javascripts/users/settings/organization.js +++ b/app/assets/javascripts/users/settings/organization.js @@ -1,5 +1,4 @@ //= require datatables -//= require users/settings/organizations/add_user_modal var usersDatatable = null; @@ -236,8 +235,19 @@ function initRemoveUsers() { ); } +function initReloadPageAfterInviteUsers() { + $('[data-id=org-invite-users-modal]') + .on('hidden.bs.modal', function() { + if (!_.isUndefined($(this).attr('data-invited'))) { + // Reload the whole table + usersDatatable.ajax.reload(); + } + }); +} + initEditName(); initEditDescription(); initUsersTable(); initUpdateRoles(); -initRemoveUsers(); \ No newline at end of file +initRemoveUsers(); +initReloadPageAfterInviteUsers(); diff --git a/app/assets/javascripts/users/settings/organizations/add_user_modal.js b/app/assets/javascripts/users/settings/organizations/add_user_modal.js deleted file mode 100644 index dcdfbab68..000000000 --- a/app/assets/javascripts/users/settings/organizations/add_user_modal.js +++ /dev/null @@ -1,171 +0,0 @@ -/* Global selectors */ -var modal = $("#add-user-modal"); -var modalContent = modal.find(".modal-content"); -var invitingExisting = true; -var inviteButton = $("[data-id='invite-btn']"); -var inviteLinks = $("[data-action='invite']"); -var inviteExistingCollapsible = $("#invite-existing"); -var inviteExistingForm = $("[data-id='invite-existing-form']"); -var inviteExistingQuery = $("#existing_query"); -var inviteExistingResults = $("#invite-existing-results"); -var inviteNewCollapsible = $("#invite-new"); -var inviteNewForm = $("[data-id='invite-new-form']"); -var inviteNewRoleInput = $("[data-id='new-user-role-input']"); -var inviteNewNameInput = $("[data-id='invite-new-name-input']"); -var inviteNewEmailInput = $("[data-id='invite-new-email-input']"); - -function disableInviteBtn() { - inviteButton.attr("disabled", "disabled"); -} -function enableInviteBtn() { - inviteButton.removeAttr("disabled"); -} - -/** - * General modal configuration & toggling. - */ -modal -.on("shown.bs.modal", function() { - // Focus the invite existing input - inviteExistingQuery.focus(); - invitingExisting = true; -}) -.on("hidden.bs.modal", function() { - // Disable invite button, - // reset forms, reset rendered content - disableInviteBtn(); - inviteExistingForm.clearFormFields(); - inviteExistingForm.clearFormErrors(); - inviteExistingResults.html(""); - inviteNewForm.clearFormFields(); - inviteNewForm.clearFormErrors(); -}); - -inviteExistingCollapsible -.on("hidden.bs.collapse", function() { - // Reset form & rendered content - inviteExistingForm.clearFormFields(); - inviteExistingForm.clearFormErrors(); - inviteExistingResults.html(""); -}) -.on("hide.bs.collapse", function() { - // Disable invite button - disableInviteBtn(); -}) -.on("shown.bs.collapse", function() { - // Focus input when collapsible is shown - inviteExistingQuery.focus(); - invitingExisting = true; -}); - -inviteNewCollapsible -.on("hidden.bs.collapse", function() { - // Reset form - inviteNewForm.clearFormFields(); - inviteNewForm.clearFormErrors(); -}) -.on("hide.bs.collapse", function() { - // Disable invite button - disableInviteBtn(); -}) -.on("shown.bs.collapse", function() { - // Focus input when collapsible is shown - inviteNewNameInput.focus(); - invitingExisting = false; -}); - -// Invite links simply submit either of the forms -inviteLinks.on("click", function() { - var $this = $(this); - - if (invitingExisting) { - var form = - inviteExistingResults - .find("form[data-id='create-user-organization-form']"); - - // Set the role value in the form - form - .find("[data-id='existing-user-role-input']") - .attr("value", $this.attr("data-value")); - - // Submit the form inside "invite existing" - animateSpinner(modalContent); - form.submit(); - } else { - // Set the role value in the form - inviteNewRoleInput - .attr("value", $this.attr("data-value")); - - // Submit the form inside "invite new" - animateSpinner(modalContent); - inviteNewForm.submit(); - } -}); - -/** - * Invite existing user functionality. - */ - -// Invite existing form submission -modal -.on("ajax:success", inviteExistingForm.selector, function(ev, data, status) { - // Clear form errors - inviteExistingForm.clearFormErrors(); - - // Alright, render the html - inviteExistingResults.html(data.html); - - // Disable invite button - disableInviteBtn(); -}) -.on("ajax:error", inviteExistingForm.selector, function(ev, data, status) { - //Clear previous info - inviteExistingResults.html(""); - - // Display form errors - inviteExistingForm.renderFormErrors("", data.responseJSON); -}); - -// Update values & enable "invite" button -// when user clicks on existing user -inviteExistingResults -.on("change", "[data-action='select-existing-user']", function() { - var $this = $(this); - // Set the hidden input user ID - $("[data-id='existing-user-id-input']") - .attr("value", $this.attr("data-user-id")); - - // Enable button - enableInviteBtn(); -}); - -/** - * Invite new user functionality. - */ - -inviteNewForm -.on("ajax:success", function(ev, data, status) { - // Reload the page - location.reload(); -}) -.on("ajax:error", function(ev, data, status) { - // Render form errors - animateSpinner(modalContent, false); - $(this).renderFormErrors("user", data.responseJSON); -}); - - -// Enable/disable invite button depending whether -// any of the new user inputs are empty -inviteNewForm -.on("input", "input[data-role='input']", function() { - if ( - _.isEmpty(inviteNewNameInput.val()) || - _.isEmpty(inviteNewEmailInput.val()) - ) { - disableInviteBtn(); - } else { - enableInviteBtn(); - } -}); - diff --git a/app/assets/stylesheets/themes/scinote.scss b/app/assets/stylesheets/themes/scinote.scss index dd40ded79..8ac56d026 100644 --- a/app/assets/stylesheets/themes/scinote.scss +++ b/app/assets/stylesheets/themes/scinote.scss @@ -1496,3 +1496,30 @@ html.turbolinks-progress-bar::before { width: 100px; } } + +.modal-invite-users { + .bootstrap-tagsinput { + min-width: 450px; + } + + .results-container .alert { + margin-bottom: 10px; + padding: 5px; + } + + .results-container .results-wrap { + max-height: 400px; + overflow-y: auto; + padding-right: 10px; + } + + .org-selector .heading { + margin-top: 15px; + margin-bottom: 5px; + + input[type=checkbox] { + vertical-align: middle; + margin: 0; + } + } +} diff --git a/app/controllers/users/invitations_controller.rb b/app/controllers/users/invitations_controller.rb index ff1e7b417..66218a936 100644 --- a/app/controllers/users/invitations_controller.rb +++ b/app/controllers/users/invitations_controller.rb @@ -1,30 +1,164 @@ -class Users::InvitationsController < Devise::InvitationsController +module Users + class InvitationsController < Devise::InvitationsController + include UsersGenerator - def update - @org = Organization.new - @org.name = params[:organization][:name] + before_action :check_invite_users_permission, only: :invite_users - super do |user| - if user.errors.empty? - @org.created_by = user - @org.save + def update + @org = Organization.new + @org.name = params[:organization][:name] - UserOrganization.create( - user: user, - organization: @org, - role: 'admin' - ) + super do |user| + if user.errors.empty? + @org.created_by = user + @org.save + + UserOrganization.create( + user: user, + organization: @org, + role: 'admin' + ) + end end end - end - def accept_resource - resource = super - - if not @org.valid? - resource.errors.add(:base, @org.errors.to_a.first) + def accept_resource + resource = super + resource.errors.add(:base, @org.errors.to_a.first) unless @org.valid? + resource end - resource + def invite_users + @invite_results = [] + @too_many_emails = false + + cntr = 0 + @emails.each do |email| + cntr += 1 + + if cntr > Constants::INVITE_USERS_LIMIT + @too_many_emails = true + break + end + + password = generate_user_password + + # Check if user already exists + user = nil + user = User.find_by_email(email) if User.exists?(email: email) + + result = { email: email } + + if user.present? + result[:status] = :user_exists + result[:user] = user + else + # Validate the user data + error = !(Constants::BASIC_EMAIL_REGEX === email) + error = validate_user(email, email, password).count > 0 unless error + + if !error + user = User.invite!( + full_name: email, + email: email, + initials: email.upcase[0..1], + skip_invitation: true + ) + + result[:status] = :user_created + result[:user] = 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 + else + # Return invalid status + result[:status] = :user_invalid + end + end + + if @org.present? && result[:status] != :user_invalid + if UserOrganization.exists?(user: user, organization: @org) + user_org = + UserOrganization.where(user: user, organization: @org).first + + result[:status] = :user_exists_and_in_org + elsif result[:status] == :user_exists && !user.confirmed? + # We don't want to allow inviting unconfirmed + # users (that were not invited as part of this action) + # into organizations + result[:status] = :user_exists_unconfirmed + else + # Also generate user organization relation + user_org = UserOrganization.new( + user: user, + organization: @org, + role: @role + ) + user_org.save + + generate_notification( + @user, + user, + user_org.role_str, + user_org.organization + ) + + if result[:status] == :user_exists + result[:status] = :user_exists_invited_to_org + else + result[:status] = :user_created_invited_to_org + end + end + + result[:user_org] = user_org + end + + @invite_results << result + end + + respond_to do |format| + format.json do + render json: { + html: render_to_string( + partial: 'shared/invite_users_modal_results.html.erb' + ) + } + end + end + end + + private + + def generate_notification(user, target_user, role, org) + title = I18n.t('notifications.assign_user_to_organization', + assigned_user: target_user.name, + role: role, + organization: org.name, + assigned_by_user: user.name) + + message = "#{I18n.t('search.index.organization')} #{org.name}" + notification = Notification.create( + type_of: :assignment, + title: ActionController::Base.helpers.sanitize(title), + message: ActionController::Base.helpers.sanitize(message) + ) + + if target_user.assignments_notification + UserNotification.create(notification: notification, user: target_user) + end + end + + def check_invite_users_permission + @user = current_user + @emails = params[:emails] + @org = Organization.find_by_id(params['organizationId']) + @role = params['role'] + + render_403 if @emails && @emails.empty? + render_403 if @org && !is_admin_of_organization(@org) + render_403 if @role && !UserOrganization.roles.keys.include?(@role) + end end end diff --git a/app/controllers/users/settings_controller.rb b/app/controllers/users/settings_controller.rb index 931773186..9aa0b3220 100644 --- a/app/controllers/users/settings_controller.rb +++ b/app/controllers/users/settings_controller.rb @@ -21,13 +21,7 @@ class Users::SettingsController < ApplicationController :destroy_organization, :organization_name, :organization_description, - :search_organization_users, - :organization_users_datatable, - :create_user_and_user_organization - ] - - before_action :check_create_user_organization_permission, only: [ - :create_user_organization + :organization_users_datatable ] before_action :check_user_organization_permission, only: [ @@ -118,57 +112,6 @@ class Users::SettingsController < ApplicationController end end - def search_organization_users - respond_to do |format| - format.json { - if params.include?(:existing_query) && params[:existing_query].strip - query = params[:existing_query].strip - if query.length < Constants::NAME_MIN_LENGTH - render json: { - "existing_query": [ - t('general.query.length_too_short', - min_length: Constants::NAME_MIN_LENGTH) - ] - }, - status: :unprocessable_entity - elsif query.length > Constants::NAME_MAX_LENGTH - render json: { - "existing_query": [ - t('general.query.length_too_long', - max_length: Constants::NAME_MAX_LENGTH) - ] - }, - status: :unprocessable_entity - else - # Okay, query exists and is non-blank, find users - nr_of_results = User.search(true, query, @org).count - - - users = User.search(false, query, @org) - .limit(Constants::MODAL_SEARCH_LIMIT) - - nr_of_members = User.organization_search(false, query, @org).count - - render json: { - html: render_to_string({ - partial: "users/settings/organizations/existing_users_search_results.html.erb", - locals: { - users: users, - nr_of_results: nr_of_results, - nr_of_members: nr_of_members, - org: @org, - query: query - } - }) - } - end - else - render json: {}, status: :bad_request - end - } - end - end - def organization_users_datatable respond_to do |format| format.json { @@ -213,101 +156,6 @@ class Users::SettingsController < ApplicationController redirect_to action: :organizations end - def create_user_organization - @new_user_org = UserOrganization.new(create_user_organization_params) - - # Check if such association doesn't exist already - if !UserOrganization.where( - user: @new_user_org.user, - organization: @new_user_org.organization - ).exists? && @new_user_org.save - - generate_notification(@user_organization.user, - @new_user_org.user, - @new_user_org.role_str, - @new_user_org.organization) - - flash[:notice] = I18n.t( - 'users.settings.organizations.edit.modal_add_user.existing_flash_success', - user: @new_user_org.user.full_name, - role: @new_user_org.role_str - ) - else - flash[:alert] = - I18n.t('users.settings.organizations.edit.modal_add_user.existing_flash_error') - end - - # Either way, redirect back to organization page - redirect_to action: :organization, - organization_id: @new_user_org.organization_id - end - - def create_user_and_user_organization - respond_to do |format| - # User & organization - # parameters are already taken care of, - # so only role needs to be verified - if !params.include? :role or - !UserOrganization.roles.keys.include? params[:role] - format.json { - render json: "Invalid role provided", - status: :unprocessable_entity - } - else - password = generate_user_password - user_params = create_user_params - full_name = user_params[:full_name] - email = user_params[:email] - - # Validate the user data - errors = validate_user(full_name, email, password) - - if errors.count == 0 - @user = User.invite!( - full_name: full_name, - email: email, - initials: full_name.split(" ").map{|w| w[0].upcase}.join[0..3], - skip_invitation: true - ) - - # 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 - - # Also generate user organization relation - @user_org = UserOrganization.new( - user: @user, - organization: @org, - role: params[:role] - ) - @user_org.save - - # Flash message - flash[:notice] = t( - "users.settings.organizations.edit.modal_add_user.new_flash_success", - user: @user.full_name, - role: @user_org.role_str, - email: @user.email - ) - flash.keep - - # Return success! - format.json { - render json: { - status: :ok - } - } - else - format.json { - render json: errors, - status: :unprocessable_entity - } - end - end - end - end - def update_user_organization respond_to do |format| if @user_org.update(update_user_organization_params) @@ -520,13 +368,6 @@ class Users::SettingsController < ApplicationController end end - def check_create_user_organization_permission - @org = Organization.find_by_id(params[:user_organization][:organization_id]) - unless is_admin_of_organization(@org) - render_403 - end - end - def check_user_organization_permission @user_org = UserOrganization.find_by_id(params[:user_organization_id]) @org = @user_org.organization @@ -565,41 +406,12 @@ class Users::SettingsController < ApplicationController ) end - def create_user_organization_params - params.require(:user_organization).permit( - :user_id, - :organization_id, - :role - ) - end - def update_user_organization_params params.require(:user_organization).permit( :role ) end - def generate_notification(user, target_user, role, org) - title = I18n.t('notifications.assign_user_to_organization', - assigned_user: target_user.name, - role: role, - organization: org.name, - assigned_by_user: user.name) - - message = "#{I18n.t('search.index.organization')} #{org.name}" - notification = Notification.create( - type_of: :assignment, - title: - ActionController::Base.helpers.sanitize(title), - message: - ActionController::Base.helpers.sanitize(message), - ) - - if target_user.assignments_notification - UserNotification.create(notification: notification, user: target_user) - end - end - def reset_user_current_organization(user_org) ids = user_org.user.organizations_ids ids -= [user_org.organization.id] diff --git a/app/models/user.rb b/app/models/user.rb index 15fc25fdd..2e868538f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -133,39 +133,6 @@ class User < ActiveRecord::Base .distinct end - # Search all active users inside given organization for - # username & email. - def self.organization_search( - active_only, - query = nil, - organization = nil - ) - - if !organization.present? - result = nil - else - - result = User.all - - if active_only - result = result.where.not(confirmed_at: nil) - end - - ignored_ids = - UserOrganization - .select(:user_id) - .where(organization_id: organization.id) - result = - result - .where("users.id IN (?)", ignored_ids) - - result - .where_attributes_like([:full_name, :email], query) - .distinct - end - end - - def empty_avatar(name, size) file_ext = name.split(".").last self.avatar_file_name = name diff --git a/app/views/shared/_invite_users_modal.html.erb b/app/views/shared/_invite_users_modal.html.erb new file mode 100644 index 000000000..b2e17a57b --- /dev/null +++ b/app/views/shared/_invite_users_modal.html.erb @@ -0,0 +1,145 @@ +<% +# How to use this modal: +# 1. Render it in the page (HTML) of your choice +# 2. Add an element (e.g. ) with following attributes: +# * data-trigger="invite-users", +# * data-modal-id="modal-id", +# +# Modal parameters: +# * modal_id: unique id so the JS works if multiple modals are present +# on the same page +# * type: +# * 'invite_to_org' => params: organization +# * 'invite_to_org_with_role' => params: organization, role +# * 'invite', +# * 'invite_with_org_selector', +# * 'invite_with_org_selector_and_role' => params: role +# * organization: invite users to the specified organization +# * role: all users are invited as the specified role +# * (optional) text_title: custom title text for modal +# * (optional) text_invite_heading: custom invite heading text for modal +%> +<% +text_title ||= nil +text_invite_heading ||= nil +invite_to_org = type.in?(%w(invite_to_org invite_to_org_with_role)) +%> + diff --git a/app/views/shared/_invite_users_modal_results.html.erb b/app/views/shared/_invite_users_modal_results.html.erb new file mode 100644 index 000000000..b37644336 --- /dev/null +++ b/app/views/shared/_invite_users_modal_results.html.erb @@ -0,0 +1,54 @@ +
+ <%= t('invite_users.results.heading') %> +
+
+
+<% @invite_results.each do |result| %> +<% if result[:status] == :user_exists %> + +<% elsif result[:status] == :user_exists_unconfirmed %> + +<% elsif result[:status] == :user_exists_and_in_org %> + +<% elsif result[:status] == :user_exists_invited_to_org %> + +<% elsif result[:status] == :user_created %> + +<% elsif result[:status] == :user_created_invited_to_org %> + +<% elsif result[:status] == :user_invalid %> + +<% end %> +<% end %> +<% if @too_many_emails %> +<%= t('invite_users.results.too_many_emails', nr: Constants::INVITE_USERS_LIMIT) %> +<% end %> +
\ No newline at end of file diff --git a/app/views/shared/_navigation.html.erb b/app/views/shared/_navigation.html.erb index 34ab19eb4..1831d92f8 100644 --- a/app/views/shared/_navigation.html.erb +++ b/app/views/shared/_navigation.html.erb @@ -181,7 +181,7 @@ aria-expanded="false">
-
- <%= link_to "#", class: "btn btn-primary", data: { toggle: "modal", target: "#add-user-modal" } do %> + <%= t("users.settings.organizations.edit.add_user") %> - <% end %> +
@@ -95,7 +95,15 @@ <%= render partial: "users/settings/organizations/name_modal.html.erb" %> <%= render partial: "users/settings/organizations/description_modal.html.erb" %> -<%= render partial: "users/settings/organizations/add_user_modal.html.erb", locals: { org: @org } %> +<%= render( + partial: 'shared/invite_users_modal.html.erb', + locals: { + modal_id: 'org-invite-users-modal', + type: 'invite_to_org', + organization: @org + } + ) +%> <%= render partial: "users/settings/organizations/destroy_modal.html.erb", locals: { org: @org } %> <%= render partial: "users/settings/organizations/destroy_user_organization_modal.html.erb" %> <%= stylesheet_link_tag 'datatables' %> diff --git a/app/views/users/settings/organizations/_add_user_modal.html.erb b/app/views/users/settings/organizations/_add_user_modal.html.erb deleted file mode 100644 index 0b41bf8d3..000000000 --- a/app/views/users/settings/organizations/_add_user_modal.html.erb +++ /dev/null @@ -1,92 +0,0 @@ - \ No newline at end of file diff --git a/app/views/users/settings/organizations/_existing_users_search_results.html.erb b/app/views/users/settings/organizations/_existing_users_search_results.html.erb deleted file mode 100644 index dc9c23f0f..000000000 --- a/app/views/users/settings/organizations/_existing_users_search_results.html.erb +++ /dev/null @@ -1,36 +0,0 @@ -<% if users.count > 0 %> - <%= bootstrap_form_for UserOrganization.new, url: create_user_organization_path, data: { id: "create-user-organization-form" } do %> - - - -
- - <% users.each do |user| %> - - <% end %> -
- <% if nr_of_members > 0 %> -
- <%= t("users.settings.organizations.edit.modal_add_user.existing_users_members_smalltext", count: nr_of_members) %> -
- <% end %> - <% if nr_of_results > users.count %> -
- <%= t("users.settings.organizations.edit.modal_add_user.existing_users_smalltext", count: users.count) %> -
- <% end %> - <% end %> -<% else%> - - <% if nr_of_members > 0 %> - <%= t("users.settings.organizations.edit.modal_add_user.no_existing_users_members_smalltext", count: nr_of_members) %> - <% else %> - <%= t("users.settings.organizations.edit.modal_add_user.no_existing_users") %> - <% end %> - -<% end %> \ No newline at end of file diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb index f1a105b3b..55f62d834 100644 --- a/config/initializers/assets.rb +++ b/config/initializers/assets.rb @@ -59,6 +59,7 @@ Rails.application.config.assets.precompile += %w(assets.js) Rails.application.config.assets.precompile += %w(comments.js) Rails.application.config.assets.precompile += %w(projects/show.js) Rails.application.config.assets.precompile += %w(notifications.js) +Rails.application.config.assets.precompile += %w(users/invite_users_modal.js) # Libraries needed for Handsontable formulas Rails.application.config.assets.precompile += %w(lodash.js) diff --git a/config/initializers/constants.rb b/config/initializers/constants.rb index becd940ec..ddc6bff5e 100644 --- a/config/initializers/constants.rb +++ b/config/initializers/constants.rb @@ -41,6 +41,9 @@ class Constants # Activity limited query/display elements for pages ACTIVITY_AND_NOTIF_SEARCH_LIMIT = 10 + # Maximum number of users that can be invited in a single action + INVITE_USERS_LIMIT = 20 + #============================================================================= # File and data memory size #============================================================================= @@ -196,6 +199,9 @@ class Constants 'text/plain' ].freeze + # Very basic regex to check for validity of emails + BASIC_EMAIL_REGEX = /^[^@]+@[^@]+\.[^@]+$/ + # Organization name for default admin user DEFAULT_PRIVATE_ORG_NAME = 'My projects'.freeze diff --git a/config/locales/en.yml b/config/locales/en.yml index 4d49ceab0..a4410acb5 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1209,32 +1209,6 @@ en: can_delete_message: "This team can be deleted because it doesn't have any projects." delete_text: "Delete team." cannot_delete_message_projects: "Cannot delete this team. Only empty teams (without any projects) can be deleted." - modal_add_user: - title: "Invite user to team" - existing_heading: "Invite existing sciNote user" - existing_label: "Find existing user by name or email:" - existing_placeholder: "Name or email" - existing_results_title: "Choose user" - no_existing_users: "No existing users found." - existing_users_members_smalltext: - one: "The search matched 1 user that is already member of the team." - other: "The search matched %{count} users that are already members of the team." - no_existing_users_members_smalltext: - one: "No existing users that could be invited found; the search, however, matched 1 user that is already member of the team." - other: "No existing users that could be invited found; the search, however, matched %{count} users that are already members of the team." - existing_users_smalltext: "Only showing top %{count} matched results." - existing_flash_success: "User %{user} successfully invited to team as %{role}." - existing_flash_error: "Error inviting user to team." - new_heading: "Invite new user" - new_label_name: "Type in the new user's full name:" - new_placeholder_name: "Full name" - new_label_email: "Type in the new user's email:" - new_placeholder_email: "Email" - new_flash_success: "User %{user} successfully invited to team as %{role}. Confirmation email was sent to %{email}." - invite: "Invite user" - invite_guest: "as Guest" - invite_user: "as Normal user" - invite_admin: "as Administrator" modal_destroy_organization: title: "Delete team" message: "Are you sure you wish to delete team %{org}? All of the users will be removed from the team as well. This action is irreversible." @@ -1449,6 +1423,30 @@ en: head_title: "Edit protocol" no_keywords: "No keywords" + invite_users: + to_org: + title: "Invite users to team %{organization}" + heading: "Invite more people to team %{organization} and start using sciNote." + no_org: + title: "Invite users to sciNote" + heading: "Invite more people to start using sciNote." + input_subtitle: "Input one or multiple emails, confirm each email with ENTER key." + invite_to_org_heading: "Invite users to my team:" + invite_btn: "Invite user/s" + invite_guest: "as Guest/s" + invite_user: "as Normal user/s" + invite_admin: "as Administrator/s" + results: + heading: "Invitation results:" + user_exists: "User is already a member of sciNote." + user_exists_unconfirmed: "User is already a member of sciNote but is not confirmed yet - cannot invite to team %{organization}." + user_exists_and_in_org: "User is already a member of sciNote and team %{organization} as %{role}." + user_exists_invited_to_org: "User was already a member of sciNote - successfully invited to team %{organization} as %{role}." + user_created: "User succesfully invited to sciNote." + user_created_invited_to_org: "User successfully invited to sciNote and team %{organization} as %{role}." + user_invalid: "Invalid email." + too_many_emails: "Only invited first %{nr} emails. To invite more users, " + time: formats: full: "%d.%m.%Y %H:%M" diff --git a/config/routes.rb b/config/routes.rb index d1aca0457..01073c51f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -29,16 +29,20 @@ Rails.application.routes.draw do put "users/settings/organizations/:organization_id", to: "users/settings#update_organization", as: "update_organization" get "users/settings/organizations/:organization_id/name", to: "users/settings#organization_name", as: "organization_name" get "users/settings/organizations/:organization_id/description", to: "users/settings#organization_description", as: "organization_description" - get "users/settings/organizations/:organization_id/search", to: "users/settings#search_organization_users", as: "search_organization_users" post "users/settings/organizations/:organization_id/users_datatable", to: "users/settings#organization_users_datatable", as: "organization_users_datatable" delete "users/settings/organizations/:organization_id", to: "users/settings#destroy_organization", as: "destroy_organization" - post "users/settings/user_organizations/new", to: "users/settings#create_user_organization", as: "create_user_organization" - post "users/settings/users_organizations/new_user", to: "users/settings#create_user_and_user_organization", as: "create_user_and_user_organization" put "users/settings/user_organizations/:user_organization_id", to: "users/settings#update_user_organization", as: "update_user_organization" get "users/settings/user_organizations/:user_organization_id/leave_html", to: "users/settings#leave_user_organization_html", as: "leave_user_organization_html" get "users/settings/user_organizations/:user_organization_id/destroy_html", to: "users/settings#destroy_user_organization_html", as: "destroy_user_organization_html" delete "users/settings/user_organizations/:user_organization_id", to: "users/settings#destroy_user_organization", as: "destroy_user_organization" + # Invite users + devise_scope :user do + post '/invite', + to: 'users/invitations#invite_users', + as: 'invite_users' + end + # Notifications get 'users/:id/recent_notifications', to: 'user_notifications#recent_notifications',