Merge pull request #4100 from opf/feature/resend_invitation

resend invitation feature
pull/4136/head
Oliver Günther 9 years ago
commit 93c1812fa3
  1. 31
      app/controllers/concerns/user_invitation.rb
  2. 16
      app/controllers/users_controller.rb
  3. 3
      app/models/token.rb
  4. 12
      app/views/users/_toolbar.html
  5. 6
      config/initializers/user_invitation.rb
  6. 6
      config/locales/en.yml
  7. 1
      config/routes.rb
  8. 20
      spec/controllers/concerns/user_invitation_spec.rb
  9. 42
      spec/controllers/users_controller_spec.rb
  10. 1
      spec/factories/token_factory.rb
  11. 4
      spec/factories/user_factory.rb
  12. 59
      spec/features/users/resend_invitation_spec.rb
  13. 61
      spec/views/users/edit.html.erb_spec.rb
  14. 2
      spec_legacy/unit/token_spec.rb

@ -27,7 +27,17 @@
#++ #++
module UserInvitation module UserInvitation
EVENT_NAME = 'user_invited' module Events
class << self
def user_invited
'user_invited'
end
def user_reinvited
'user_reinvited'
end
end
end
module_function module_function
@ -61,6 +71,23 @@ module UserInvitation
invite_user! user invite_user! user
end end
##
# Sends a new invitation to the user with a new token.
#
# @param user_id [Integer] ID of the user to be re-invited.
# @return [Token] The new token used for the invitation.
def reinvite_user(user_id)
clear_tokens user_id
Token.create(user_id: user_id, action: token_action).tap do |token|
OpenProject::Notifications.send Events.user_reinvited, token
end
end
def clear_tokens(user_id)
Token.where(user_id: user_id, action: token_action).destroy_all
end
## ##
# Creates a placeholder name for the user based on their email address. # Creates a placeholder name for the user based on their email address.
# For the unlikely case that the local or domain part of the email address # For the unlikely case that the local or domain part of the email address
@ -91,7 +118,7 @@ module UserInvitation
user, token = user_invitation user user, token = user_invitation user
if token if token
OpenProject::Notifications.send(EVENT_NAME, token) OpenProject::Notifications.send(Events.user_invited, token)
user user
end end

@ -39,7 +39,8 @@ class UsersController < ApplicationController
:edit_membership, :edit_membership,
:destroy_membership, :destroy_membership,
:destroy, :destroy,
:deletion_info] :deletion_info,
:resend_invitation]
before_filter :require_login, only: [:deletion_info] # should also contain destroy but post data can not be redirected before_filter :require_login, only: [:deletion_info] # should also contain destroy but post data can not be redirected
before_filter :authorize_for_user, only: [:destroy] before_filter :authorize_for_user, only: [:destroy]
before_filter :check_if_deletion_allowed, only: [:deletion_info, before_filter :check_if_deletion_allowed, only: [:deletion_info,
@ -267,6 +268,19 @@ class UsersController < ApplicationController
end end
end end
def resend_invitation
token = UserInvitation.reinvite_user @user.id
if token.persisted?
flash[:notice] = I18n.t(:notice_user_invitation_resent, email: @user.mail)
else
logger.error "could not re-invite #{@user.mail}: #{token.errors.full_messages.join(' ')}"
flash[:error] = I18n.t(:notice_internal_server_error, app_title: Setting.app_title)
end
redirect_to edit_user_path(@user)
end
def destroy def destroy
# true if the user deletes him/herself # true if the user deletes him/herself
self_delete = (@user == User.current) self_delete = (@user == User.current)

@ -29,6 +29,9 @@
class Token < ActiveRecord::Base class Token < ActiveRecord::Base
belongs_to :user belongs_to :user
validates :user, presence: true
validates :action, presence: true
validates_uniqueness_of :value validates_uniqueness_of :value
before_create :delete_previous_tokens before_create :delete_previous_tokens

@ -28,6 +28,18 @@ See doc/COPYRIGHT.rdoc for more details.
++#%> ++#%>
<%= breadcrumb_toolbar(link_to(l(:label_user_plural), users_path), @user.new_record? ? l(:label_user_new) : @user.name) do %> <%= breadcrumb_toolbar(link_to(l(:label_user_plural), users_path), @user.new_record? ? l(:label_user_new) : @user.name) do %>
<% unless @user.new_record? %> <% unless @user.new_record? %>
<% if @user.invited? and current_user.admin? %>
<li class="toolbar-item">
<%= form_for(@user, html: { class: 'toolbar-item'},
url: { action: :resend_invitation },
method: :post) do |_form| %>
<button class="button" title="<%= t(:tooltip_resend_invitation) %>">
<i class="button--icon icon-mail1"></i>
<span class="button--text"><%= t(:label_resend_invitation) %></span>
</button>
<% end %>
</li>
<% end %>
<li class="toolbar-item"> <li class="toolbar-item">
<%= link_to user_path(@user), class: 'button' do %> <%= link_to user_path(@user), class: 'button' do %>
<i class="button--icon icon-user"></i> <i class="button--icon icon-user"></i>

@ -1,6 +1,10 @@
## ##
# The default behaviour is to send the user a sign-up mail # The default behaviour is to send the user a sign-up mail
# when they were invited. # when they were invited.
OpenProject::Notifications.subscribe UserInvitation::EVENT_NAME do |token| OpenProject::Notifications.subscribe UserInvitation::Events.user_invited do |token|
Delayed::Job.enqueue DeliverInvitationJob.new(token.id)
end
OpenProject::Notifications.subscribe UserInvitation::Events.user_reinvited do |token|
Delayed::Job.enqueue DeliverInvitationJob.new(token.id) Delayed::Job.enqueue DeliverInvitationJob.new(token.id)
end end

@ -882,6 +882,7 @@ en:
label_wiki_menu_item: Wiki menu item label_wiki_menu_item: Wiki menu item
label_select_main_menu_item: Select new main menu item label_select_main_menu_item: Select new main menu item
label_required_disk_storage: "Required disk storage" label_required_disk_storage: "Required disk storage"
label_resend_invitation: Resend invitation
label_change_plural: "Changes" label_change_plural: "Changes"
label_change_properties: "Change properties" label_change_properties: "Change properties"
label_change_status: "Change status" label_change_status: "Change status"
@ -1401,6 +1402,7 @@ en:
notice_unable_delete_time_entry: "Unable to delete time log entry." notice_unable_delete_time_entry: "Unable to delete time log entry."
notice_unable_delete_version: "Unable to delete version." notice_unable_delete_version: "Unable to delete version."
notice_user_missing_authentication_method: User has yet to chose a password or another way the sign in. notice_user_missing_authentication_method: User has yet to chose a password or another way the sign in.
notice_user_invitation_resent: Another invitation has been sent to %{email}.
present_access_key_value: "Your %{key_name} is: %{value}" present_access_key_value: "Your %{key_name} is: %{value}"
notice_automatic_set_of_standard_type: "Set standard type automatically." notice_automatic_set_of_standard_type: "Set standard type automatically."
notice_logged_out: "You have been logged out." notice_logged_out: "You have been logged out."
@ -2010,6 +2012,10 @@ en:
title_remove_and_delete_user: Remove the invited user from the project and delete him/her. title_remove_and_delete_user: Remove the invited user from the project and delete him/her.
tooltip_resend_invitation: >
Sends another invitation email with a fresh token in
case the old one expired or the user did not get the original email.
queries: queries:
apply_filter: Apply preconfigured filter apply_filter: Apply preconfigured filter

@ -473,6 +473,7 @@ OpenProject::Application.routes.draw do
post :change_status post :change_status
post :edit_membership post :edit_membership
post :destroy_membership post :destroy_membership
post :resend_invitation
get :deletion_info get :deletion_info
end end
end end

@ -46,4 +46,24 @@ describe UserInvitation do
expect(last).to eq '@veryopensuchproject.openpro...' expect(last).to eq '@veryopensuchproject.openpro...'
end end
end end
describe '.reinvite_user' do
let(:user) { FactoryGirl.create :invited_user }
let!(:token) { FactoryGirl.create :token, user: user, action: UserInvitation.token_action }
it 'notifies listeners of the re-invite' do
expect(OpenProject::Notifications).to receive(:send) do |event, new_token|
expect(event).to eq 'user_reinvited'
end
UserInvitation.reinvite_user user.id
end
it 'creates a new token' do
new_token = UserInvitation.reinvite_user user.id
expect(new_token.value).not_to eq token.value
expect(Token.exists?(token.id)).to eq false
end
end
end end

@ -125,6 +125,48 @@ describe UsersController, type: :controller do
end end
end end
describe 'POST resend_invitation' do
let(:invited_user) { FactoryGirl.create :invited_user }
context 'without admin rights' do
let(:normal_user) { FactoryGirl.create :user }
before do
as_logged_in_user normal_user do
post :resend_invitation, id: invited_user.id
end
end
it 'returns 403 forbidden' do
expect(response.status).to eq 403
end
end
context 'with admin rights' do
let(:admin_user) { FactoryGirl.create :admin }
before do
expect(ActionMailer::Base.deliveries).to be_empty
as_logged_in_user admin_user do
post :resend_invitation, id: invited_user.id
end
end
it 'redirects back to the edit user page' do
expect(response).to redirect_to edit_user_path(invited_user)
end
it 'sends another activation email' do
mail = ActionMailer::Base.deliveries.first.body.parts.first.body.to_s
token = Token.find_by user_id: invited_user.id, action: UserInvitation.token_action
expect(mail).to include 'activate your account'
expect(mail).to include token.value
end
end
end
describe 'POST destroy' do describe 'POST destroy' do
describe "WHEN the current user is the requested one describe "WHEN the current user is the requested one
WHEN the setting users_deletable_by_self is set to true" do WHEN the setting users_deletable_by_self is set to true" do

@ -31,6 +31,7 @@ require 'securerandom'
FactoryGirl.define do FactoryGirl.define do
factory :token do factory :token do
user user
action 'invite'
value do SecureRandom.hex(16) end value do SecureRandom.hex(16) end
factory :api_key do factory :api_key do

@ -82,6 +82,10 @@ FactoryGirl.define do
password_confirmation 'adminADMIN!' password_confirmation 'adminADMIN!'
status User::STATUSES[:locked] status User::STATUSES[:locked]
end end
factory :invited_user do
status User::STATUSES[:invited]
end
end end
factory :anonymous, class: AnonymousUser do factory :anonymous, class: AnonymousUser do
status User::STATUSES[:builtin] status User::STATUSES[:builtin]

@ -0,0 +1,59 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
feature 'resend invitation', type: :feature do
let(:current_user) { FactoryGirl.create :admin }
let(:user) { FactoryGirl.create :invited_user, mail: 'holly@openproject.com' }
before do
allow(User).to receive(:current).and_return current_user
visit edit_user_path(user)
end
scenario 'admin resends the invitation' do
click_on 'Resend invitation'
expect(page).to have_text 'Another invitation has been sent to holly@openproject.com.'
end
context 'with some error occuring' do
before do
allow(UserInvitation).to receive(:token_action).and_return(nil)
end
scenario 'resending fails' do
click_on 'Resend invitation'
expect(page).to have_text 'An error occurred'
expect(page).to have_text 'You are here: HomeAdministrationUsers'
end
end
end

@ -29,7 +29,7 @@
require 'spec_helper' require 'spec_helper'
describe 'users/edit', type: :view do describe 'users/edit', type: :view do
let(:current_user) { FactoryGirl.build :admin } let(:admin) { FactoryGirl.build :admin }
context 'authentication provider' do context 'authentication provider' do
let(:user) { let(:user) {
@ -41,7 +41,7 @@ describe 'users/edit', type: :view do
assign(:user, user) assign(:user, user)
assign(:auth_sources, []) assign(:auth_sources, [])
allow(view).to receive(:current_user).and_return(current_user) allow(view).to receive(:current_user).and_return(admin)
end end
it 'shows the authentication provider' do it 'shows the authentication provider' do
@ -58,6 +58,55 @@ describe 'users/edit', type: :view do
end end
end end
context 'with an invited user' do
let(:user) { FactoryGirl.create :invited_user }
before do
assign(:user, user)
assign(:auth_sources, [])
end
context 'for an admin' do
before do
allow(view).to receive(:current_user).and_return(admin)
render
end
it 'renders the resend invitation button' do
expect(rendered).to include I18n.t(:label_resend_invitation)
end
end
context 'for a non-admin' do
let(:non_admin) { FactoryGirl.create :user }
before do
allow(view).to receive(:current_user).and_return(non_admin)
render
end
it 'does not render the resend invitation button' do
expect(rendered).not_to include I18n.t(:label_resend_invitation)
end
end
end
context 'with a normal (not invited) user' do
let(:user) { FactoryGirl.create :user }
before do
assign(:user, user)
assign(:auth_sources, [])
allow(view).to receive(:current_user).and_return(admin)
render
end
it 'does not render the resend invitation button' do
expect(rendered).not_to include I18n.t(:label_resend_invitation)
end
end
context 'with password-based login' do context 'with password-based login' do
let(:user) { FactoryGirl.build :user, id: 42 } let(:user) { FactoryGirl.build :user, id: 42 }
@ -65,7 +114,7 @@ describe 'users/edit', type: :view do
assign :user, user assign :user, user
assign :auth_sources, [] assign :auth_sources, []
allow(view).to receive(:current_user).and_return(current_user) allow(view).to receive(:current_user).and_return(admin)
end end
context 'with password login disabled' do context 'with password login disabled' do
@ -132,7 +181,7 @@ describe 'users/edit', type: :view do
within '#password_fields' do within '#password_fields' do
expect(rendered).to have_text('Password') expect(rendered).to have_text('Password')
expect(rendered).to have_text('Confirmation') expect(rendered).to have_text('Confirmation')
end end
end end
end end
@ -143,11 +192,11 @@ describe 'users/edit', type: :view do
it "doesn't show the password and password confirmation fields" do it "doesn't show the password and password confirmation fields" do
render render
within '#password_fields' do within '#password_fields' do
expect(rendered).not_to have_text('Password') expect(rendered).not_to have_text('Password')
expect(rendered).not_to have_text('Password confirmation') expect(rendered).not_to have_text('Password confirmation')
end end
end end
end end
end end

@ -32,7 +32,7 @@ describe Token do
fixtures :all fixtures :all
it 'should create' do it 'should create' do
token = Token.new token = Token.new user: User.find(1), action: 'foobar'
token.save token.save
assert_equal 40, token.value.length assert_equal 40, token.value.length
assert !token.expired? assert !token.expired?

Loading…
Cancel
Save