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
EVENT_NAME = 'user_invited'
module Events
class << self
def user_invited
'user_invited'
end
def user_reinvited
'user_reinvited'
end
end
end
module_function
@ -61,6 +71,23 @@ module UserInvitation
invite_user! user
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.
# 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
if token
OpenProject::Notifications.send(EVENT_NAME, token)
OpenProject::Notifications.send(Events.user_invited, token)
user
end

@ -39,7 +39,8 @@ class UsersController < ApplicationController
:edit_membership,
:destroy_membership,
: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 :authorize_for_user, only: [:destroy]
before_filter :check_if_deletion_allowed, only: [:deletion_info,
@ -267,6 +268,19 @@ class UsersController < ApplicationController
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
# true if the user deletes him/herself
self_delete = (@user == User.current)

@ -29,6 +29,9 @@
class Token < ActiveRecord::Base
belongs_to :user
validates :user, presence: true
validates :action, presence: true
validates_uniqueness_of :value
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 %>
<% 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">
<%= link_to user_path(@user), class: 'button' do %>
<i class="button--icon icon-user"></i>

@ -1,6 +1,10 @@
##
# The default behaviour is to send the user a sign-up mail
# 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)
end

@ -882,6 +882,7 @@ en:
label_wiki_menu_item: Wiki menu item
label_select_main_menu_item: Select new main menu item
label_required_disk_storage: "Required disk storage"
label_resend_invitation: Resend invitation
label_change_plural: "Changes"
label_change_properties: "Change properties"
label_change_status: "Change status"
@ -1401,6 +1402,7 @@ en:
notice_unable_delete_time_entry: "Unable to delete time log entry."
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_invitation_resent: Another invitation has been sent to %{email}.
present_access_key_value: "Your %{key_name} is: %{value}"
notice_automatic_set_of_standard_type: "Set standard type automatically."
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.
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:
apply_filter: Apply preconfigured filter

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

@ -46,4 +46,24 @@ describe UserInvitation do
expect(last).to eq '@veryopensuchproject.openpro...'
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

@ -125,6 +125,48 @@ describe UsersController, type: :controller do
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 "WHEN the current user is the requested one
WHEN the setting users_deletable_by_self is set to true" do

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

@ -82,6 +82,10 @@ FactoryGirl.define do
password_confirmation 'adminADMIN!'
status User::STATUSES[:locked]
end
factory :invited_user do
status User::STATUSES[:invited]
end
end
factory :anonymous, class: AnonymousUser do
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'
describe 'users/edit', type: :view do
let(:current_user) { FactoryGirl.build :admin }
let(:admin) { FactoryGirl.build :admin }
context 'authentication provider' do
let(:user) {
@ -41,7 +41,7 @@ describe 'users/edit', type: :view do
assign(:user, user)
assign(:auth_sources, [])
allow(view).to receive(:current_user).and_return(current_user)
allow(view).to receive(:current_user).and_return(admin)
end
it 'shows the authentication provider' do
@ -58,6 +58,55 @@ describe 'users/edit', type: :view do
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
let(:user) { FactoryGirl.build :user, id: 42 }
@ -65,7 +114,7 @@ describe 'users/edit', type: :view do
assign :user, user
assign :auth_sources, []
allow(view).to receive(:current_user).and_return(current_user)
allow(view).to receive(:current_user).and_return(admin)
end
context 'with password login disabled' do
@ -132,7 +181,7 @@ describe 'users/edit', type: :view do
within '#password_fields' do
expect(rendered).to have_text('Password')
expect(rendered).to have_text('Confirmation')
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
render
within '#password_fields' do
expect(rendered).not_to have_text('Password')
expect(rendered).not_to have_text('Password confirmation')
end
end
end
end
end

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

Loading…
Cancel
Save