From ccf893261169046b6c66762d263d7971edff04ce Mon Sep 17 00:00:00 2001 From: Luka Murn Date: Thu, 3 Nov 2016 11:27:17 +0100 Subject: [PATCH 1/8] Add first version of invite users modal --- .../sitewide/invite_users_modal.js | 164 ++++++++++++++++++ app/assets/stylesheets/themes/scinote.scss | 16 ++ .../users/invitations_controller.rb | 4 + app/views/shared/_invite_users_modal.html.erb | 129 ++++++++++++++ config/initializers/assets.rb | 1 + config/locales/en.yml | 14 ++ config/routes.rb | 5 + 7 files changed, 333 insertions(+) create mode 100644 app/assets/javascripts/sitewide/invite_users_modal.js create mode 100644 app/views/shared/_invite_users_modal.html.erb diff --git a/app/assets/javascripts/sitewide/invite_users_modal.js b/app/assets/javascripts/sitewide/invite_users_modal.js new file mode 100644 index 000000000..183b4b6b6 --- /dev/null +++ b/app/assets/javascripts/sitewide/invite_users_modal.js @@ -0,0 +1,164 @@ +(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 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=tagsinput]'); + + 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 + $('[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) { + } + }); + }); + }) + .on('shown.bs.modal', function() { + tagsInput.tagsinput('focus'); + }) + .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'); + }); + } + + 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/stylesheets/themes/scinote.scss b/app/assets/stylesheets/themes/scinote.scss index dd40ded79..6b851acb0 100644 --- a/app/assets/stylesheets/themes/scinote.scss +++ b/app/assets/stylesheets/themes/scinote.scss @@ -1496,3 +1496,19 @@ html.turbolinks-progress-bar::before { width: 100px; } } + +.modal-invite-users { + .bootstrap-tagsinput { + min-width: 450px; + } + + .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..c8be3d46e 100644 --- a/app/controllers/users/invitations_controller.rb +++ b/app/controllers/users/invitations_controller.rb @@ -27,4 +27,8 @@ class Users::InvitationsController < Devise::InvitationsController resource end + + def invite_users + # TODO + end end 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..f8f2b0705 --- /dev/null +++ b/app/views/shared/_invite_users_modal.html.erb @@ -0,0 +1,129 @@ +<% +# 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 +%> + 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/locales/en.yml b/config/locales/en.yml index 4d49ceab0..e807aef08 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1449,6 +1449,20 @@ 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" + time: formats: full: "%d.%m.%Y %H:%M" diff --git a/config/routes.rb b/config/routes.rb index d1aca0457..f5dc7a41b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -39,6 +39,11 @@ Rails.application.routes.draw do 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 + post 'users/invite', + to: 'users/invitations#invite_users', + as: 'invite_users' + # Notifications get 'users/:id/recent_notifications', to: 'user_notifications#recent_notifications', From 6fe0d6b3dfc836db9b859706eca408fed4e824a3 Mon Sep 17 00:00:00 2001 From: Luka Murn Date: Sun, 6 Nov 2016 11:29:00 +0100 Subject: [PATCH 2/8] A working invite modal! --- ...ers_modal.js => invite_users_modal.js.erb} | 29 +++- app/assets/stylesheets/themes/scinote.scss | 11 ++ .../users/invitations_controller.rb | 150 +++++++++++++++--- app/views/shared/_invite_users_modal.html.erb | 5 +- .../_invite_users_modal_results.html.erb | 54 +++++++ config/initializers/constants.rb | 6 + config/locales/en.yml | 10 ++ config/routes.rb | 8 +- 8 files changed, 243 insertions(+), 30 deletions(-) rename app/assets/javascripts/sitewide/{invite_users_modal.js => invite_users_modal.js.erb} (84%) create mode 100644 app/views/shared/_invite_users_modal_results.html.erb diff --git a/app/assets/javascripts/sitewide/invite_users_modal.js b/app/assets/javascripts/sitewide/invite_users_modal.js.erb similarity index 84% rename from app/assets/javascripts/sitewide/invite_users_modal.js rename to app/assets/javascripts/sitewide/invite_users_modal.js.erb index 183b4b6b6..eb9b5b5ff 100644 --- a/app/assets/javascripts/sitewide/invite_users_modal.js +++ b/app/assets/javascripts/sitewide/invite_users_modal.js.erb @@ -6,6 +6,7 @@ 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]'); @@ -16,7 +17,12 @@ var orgSelectorDropdown = modal.find('[data-role=org-selector-dropdown]'); var orgSelectorDropdown2 = $(); - var tagsInput = modal.find('[data-role=tagsinput]'); + var tagsInput = modal.find('[data-role=tags-input]'); + + // Set max tags + tagsInput.tagsinput({ + maxTags: <%= Constants::INVITE_USERS_LIMIT %> + }); modal .on('show.bs.modal', function() { @@ -86,7 +92,7 @@ }); // Click action - $('[data-action=invite]').on('click', function() { + modal.find('[data-action=invite]').on('click', function() { animateSpinner(modalDialog); var data = { @@ -125,12 +131,26 @@ 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 @@ -144,6 +164,11 @@ // Unbind event listeners orgSelectorCheckbox.off('change'); tagsInput.off('itemAdded itemRemoved'); + + // Hide contents of the results
+ stepResultsDiv.html(''); + stepResults.hide(); + stepForm.show(); }); } diff --git a/app/assets/stylesheets/themes/scinote.scss b/app/assets/stylesheets/themes/scinote.scss index 6b851acb0..8ac56d026 100644 --- a/app/assets/stylesheets/themes/scinote.scss +++ b/app/assets/stylesheets/themes/scinote.scss @@ -1502,6 +1502,17 @@ html.turbolinks-progress-bar::before { 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; diff --git a/app/controllers/users/invitations_controller.rb b/app/controllers/users/invitations_controller.rb index c8be3d46e..c99e3920d 100644 --- a/app/controllers/users/invitations_controller.rb +++ b/app/controllers/users/invitations_controller.rb @@ -1,34 +1,138 @@ -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 - end + def invite_users + @invite_results = [] + @too_many_emails = false - def invite_users - # TODO + 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 + + 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 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/views/shared/_invite_users_modal.html.erb b/app/views/shared/_invite_users_modal.html.erb index f8f2b0705..ee1ae6368 100644 --- a/app/views/shared/_invite_users_modal.html.erb +++ b/app/views/shared/_invite_users_modal.html.erb @@ -55,12 +55,13 @@ <% end %>

-
<%= t('invite_users.input_subtitle') %> <% if type.in?(['invite_with_org_selector', 'invite_with_org_selector_and_role']) %> + <% # Only allow inviting to organizations where user is admin %> <% uos = current_user.user_organizations.where(role: UserOrganization.roles[:admin]).joins(:organization) %> <% if uos.count > 0 %>
@@ -83,7 +84,7 @@ <% end %> <% end %>
-
+
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/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 e807aef08..3c045d2a9 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1462,6 +1462,16 @@ en: 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: diff --git a/config/routes.rb b/config/routes.rb index f5dc7a41b..31507a54c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -40,9 +40,11 @@ Rails.application.routes.draw do delete "users/settings/user_organizations/:user_organization_id", to: "users/settings#destroy_user_organization", as: "destroy_user_organization" # Invite users - post 'users/invite', - to: 'users/invitations#invite_users', - as: 'invite_users' + devise_scope :user do + post '/invite', + to: 'users/invitations#invite_users', + as: 'invite_users' + end # Notifications get 'users/:id/recent_notifications', From a188984edeb3e8c0831774e9b2355eda96d526ee Mon Sep 17 00:00:00 2001 From: Luka Murn Date: Sun, 6 Nov 2016 11:37:53 +0100 Subject: [PATCH 3/8] Allow strings of modal to be replaced --- app/views/shared/_invite_users_modal.html.erb | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/app/views/shared/_invite_users_modal.html.erb b/app/views/shared/_invite_users_modal.html.erb index ee1ae6368..df478c844 100644 --- a/app/views/shared/_invite_users_modal.html.erb +++ b/app/views/shared/_invite_users_modal.html.erb @@ -16,6 +16,12 @@ # * '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 %> @@ -48,10 +58,14 @@