diff --git a/app/assets/javascripts/admin_users.js b/app/assets/javascripts/admin_users.js index 89e974f8d3..ad9f42800c 100644 --- a/app/assets/javascripts/admin_users.js +++ b/app/assets/javascripts/admin_users.js @@ -44,14 +44,21 @@ // Hide password fields when non-internal authentication source is selected function on_auth_source_change() { var passwordFields = jQuery('#password_fields'), - passwordInputs = passwordFields.find('#user_password, #user_password_confirmation'); + passwordInputs = passwordFields.find('#user_password, #user_password_confirmation'), + newUserLogin = jQuery('#new_user_login'); if (this.value === '') { passwordFields.show(); - passwordInputs.removeAttr('disabled'); + passwordInputs.removeProp('disabled'); + + newUserLogin.hide(); + newUserLogin.find('input').prop('disabled', true); } else { passwordFields.hide(); passwordInputs.prop('disabled', 'disabled'); + + newUserLogin.show(); + newUserLogin.find('input').prop('disabled', false); } } diff --git a/app/assets/javascripts/members_select_boxes.js b/app/assets/javascripts/members_select_boxes.js index e848664dd6..d76e4ea4c1 100644 --- a/app/assets/javascripts/members_select_boxes.js +++ b/app/assets/javascripts/members_select_boxes.js @@ -103,10 +103,5 @@ jQuery(document).ready(function($) { }); }; - memberstab = $('#tab-members').first(); - if ((memberstab !== null) && (memberstab.hasClass("selected"))) { - init_members_cb(); - } else { - memberstab.click(init_members_cb); - } + init_members_cb(); }); diff --git a/app/assets/stylesheets/content/_accounts.sass b/app/assets/stylesheets/content/_accounts.sass index de8a58f624..73eed58983 100644 --- a/app/assets/stylesheets/content/_accounts.sass +++ b/app/assets/stylesheets/content/_accounts.sass @@ -38,6 +38,14 @@ float: right text-align: right +#content .login-auth-providers.wide + width: auto + text-align: center + + a.auth-provider + float: none + display: inline-block + // use id selectors to be specific enough to override general content and top-menu definitions #content .login-auth-providers, #top-menu #nav-login-content .login-auth-providers width: 471px diff --git a/app/controllers/account_controller.rb b/app/controllers/account_controller.rb index e04de8f79e..f59d7b6136 100644 --- a/app/controllers/account_controller.rb +++ b/app/controllers/account_controller.rb @@ -113,28 +113,12 @@ class AccountController < ApplicationController def register return self_registration_disabled unless allow_registration? + @user = invited_user + if request.get? - session[:auth_source_registration] = nil - @user = User.new(language: Setting.default_language) + registration_through_invitation! else - @user = User.new - @user.admin = false - @user.register - if session[:auth_source_registration] - # on-the-fly registration via omniauth or via auth source - if pending_omniauth_registration? - register_via_omniauth(@user, session, permitted_params) - else - register_and_login_via_authsource(@user, session, permitted_params) - end - else - @user.attributes = permitted_params.user - @user.login = params[:user][:login] - @user.password = params[:user][:password] - @user.password_confirmation = params[:user][:password_confirmation] - - register_user_according_to_setting @user - end + self_registration! end end @@ -142,7 +126,7 @@ class AccountController < ApplicationController allow = Setting.self_registration? && !OpenProject::Configuration.disable_password_login? get = request.get? && allow - post = request.post? && (session[:auth_source_registration] || allow) + post = (request.post? || request.patch?) && (session[:auth_source_registration] || allow) get || post end @@ -153,17 +137,82 @@ class AccountController < ApplicationController # Token based account activation def activate - return redirect_to(home_url) unless Setting.self_registration? && params[:token] - token = Token.find_by(action: 'register', value: params[:token].to_s) - redirect_to(home_url) && return unless token and !token.expired? + token = Token.find_by value: params[:token].to_s + + if token && token.action == 'register' && Setting.self_registration? + activate_self_registered token + else + activate_invited token + end + end + + def activate_self_registered(token) user = token.user - redirect_to(home_url) && return unless user.registered? - user.activate - if user.save - token.destroy - flash[:notice] = l(:notice_account_activated) + + if not user.registered? + if user.active? + flash[:notice] = I18n.t(:notice_account_already_activated) + else + flash[:error] = I18n.t(:notice_activation_failed) + end + + redirect_to home_url + else + user.activate + + if user.save + token.destroy + flash[:notice] = I18n.t(:notice_account_activated) + else + flash[:error] = I18n.t(:notice_activation_failed) + end + + redirect_to signin_path + end + end + + def activate_by_token(token) + if token.nil? || token.expired? || !token.user.invited? + flash[:error] = I18n.t(:notice_account_invalid_token) + + redirect_to home_url + else + activate_invited token end - redirect_to action: 'login' + end + + def activate_invited(token) + session[:invitation_token] = token.value + user = token.user + + if user.auth_source + activate_through_auth_source user + else + activate_user user + end + end + + def activate_user(user) + if Concerns::OmniauthLogin.direct_login? + direct_login user + elsif OpenProject::Configuration.disable_password_login? + flash[:notice] = I18n.t('account.omniauth_login') + + redirect_to signin_path + else + redirect_to account_register_path + end + end + + def activate_through_auth_source(user) + session[:auth_source_registration] = { + login: user.login, + auth_source_id: user.auth_source_id + } + + flash[:notice] = I18n.t('account.auth_source_login', login: user.login).html_safe + + redirect_to signin_path(username: user.login) end # Process a password change form, used when the user is forced @@ -199,6 +248,46 @@ class AccountController < ApplicationController private + def registration_through_invitation! + session[:auth_source_registration] = nil + + if @user.nil? + @user = User.new(language: Setting.default_language) + elsif user_with_placeholder_name?(@user) + # force user to give their name + @user.firstname = nil + @user.lastname = nil + end + end + + def self_registration! + if @user.nil? + @user = User.new + @user.admin = false + @user.register + end + + if session[:auth_source_registration] + # on-the-fly registration via omniauth or via auth source + if pending_omniauth_registration? + register_via_omniauth(@user, session, permitted_params) + else + register_and_login_via_authsource(@user, session, permitted_params) + end + else + @user.attributes = permitted_params.user + @user.login = params[:user][:login] + @user.password = params[:user][:password] + @user.password_confirmation = params[:user][:password_confirmation] + + register_user_according_to_setting @user + end + end + + def user_with_placeholder_name?(user) + user.firstname == user.login and user.login == user.mail + end + def direct_login(user) if flash.empty? ps = {}.tap do |p| @@ -233,7 +322,7 @@ class AccountController < ApplicationController end def password_authentication(username, password) - user = User.try_to_login(username, password) + user = User.try_to_login(username, password, session) if user.nil? # login failed, now try to find out why and do the appropriate thing user = User.find_by_login(username) @@ -251,6 +340,8 @@ class AccountController < ApplicationController else invalid_credentials end + elsif user and user.invited? + invited_account_not_activated(user) else # incorrect password invalid_credentials @@ -346,6 +437,8 @@ class AccountController < ApplicationController # Register a user depending on Setting.self_registration def register_user_according_to_setting(user, opts = {}, &block) + return register_automatically(user, opts, &block) if user.invited? + case Setting.self_registration when '1' register_by_email_activation(user, opts, &block) @@ -433,6 +526,13 @@ class AccountController < ApplicationController end end + def invited_account_not_activated(user) + logger.warn "Failed login for '#{params[:username]}' from #{request.remote_ip}" \ + " at #{Time.now.utc} (invited, NOT ACTIVATED)" + + flash[:error] = I18n.t('account.error_inactive_activation_by_mail') + end + # Log an attempt to log in to a locked account or with invalid credentials # and show a flash message. def invalid_credentials(flash_now: true) @@ -464,4 +564,12 @@ class AccountController < ApplicationController redirect_back_or_default controller: '/my', action: 'page' end end + + def invited_user + if session.include? :invitation_token + token = Token.find_by(value: session[:invitation_token]) + + token.user + end + end end diff --git a/app/controllers/auth_sources_controller.rb b/app/controllers/auth_sources_controller.rb index b884f18f89..a7f2215af9 100644 --- a/app/controllers/auth_sources_controller.rb +++ b/app/controllers/auth_sources_controller.rb @@ -84,9 +84,12 @@ class AuthSourcesController < ApplicationController def destroy @auth_source = AuthSource.find(params[:id]) - unless @auth_source.users.first + if @auth_source.users.empty? @auth_source.destroy - flash[:notice] = l(:notice_successful_delete) + + flash[:notice] = t(:notice_successful_delete) + else + flash[:warning] = t(:notice_wont_delete_auth_source) end redirect_to action: 'index' end diff --git a/app/controllers/concerns/omniauth_login.rb b/app/controllers/concerns/omniauth_login.rb index 84bc2f5bc0..ba79db8dac 100644 --- a/app/controllers/concerns/omniauth_login.rb +++ b/app/controllers/concerns/omniauth_login.rb @@ -43,7 +43,18 @@ module Concerns::OmniauthLogin # Set back url to page the omniauth login link was clicked on params[:back_url] = request.env['omniauth.origin'] - user = User.find_or_initialize_by identity_url: identity_url_from_omniauth(auth_hash) + + user = + if session.include? :invitation_token + tok = Token.find_by value: session[:invitation_token] + u = tok.user + u.identity_url = identity_url_from_omniauth(auth_hash) + tok.destroy + session.delete :invitation_token + u + else + User.find_or_initialize_by identity_url: identity_url_from_omniauth(auth_hash) + end decision = OpenProject::OmniAuth::Authorization.authorized? auth_hash if decision.approve? @@ -83,7 +94,7 @@ module Concerns::OmniauthLogin private def authorization_successful(user, auth_hash) - if user.new_record? + if user.new_record? || user.invited? create_user_from_omniauth user, auth_hash else if user.active? @@ -144,7 +155,7 @@ module Concerns::OmniauthLogin def fill_user_fields_from_omniauth(user, auth) user.update_attributes omniauth_hash_to_user_attributes(auth) - user.register + user.register unless user.invited? user end diff --git a/app/controllers/concerns/user_invitation.rb b/app/controllers/concerns/user_invitation.rb new file mode 100644 index 0000000000..f4d7a71f41 --- /dev/null +++ b/app/controllers/concerns/user_invitation.rb @@ -0,0 +1,108 @@ +#-- 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. +#++ + +module UserInvitation + EVENT_NAME = 'user_invited' + + module_function + + ## + # Creates an invited user with the given email address. + # If no first and last is given it will default to 'OpenProject User' + # for the first name and 'To-be' for the last name. + # The default login is the email address. + # + # @param email E-Mail address the invitation is sent to. + # @param login User's login (optional) + # @param first_name The user's first name (optional) + # @param last_name The user's last name (optional) + # + # @yield [user] Allows modifying the created user before saving it. + # + # @return The invited user. If the invitation failed, calling `#registered?` + # on the returned user will yield `false`. Check for validation errors + # in that case. + def invite_new_user(email:, login: nil, first_name: nil, last_name: nil) + user = User.new login: login || email, + mail: email, + firstname: first_name || email, + lastname: last_name || '(invited)' + + yield user if block_given? + + invite_user! user + end + + ## + # Invites the given user. An email will be sent to their email address + # containing the token necessary for the user to register. + # + # Validates and saves the given user. The invitation will fail if the user is invalid. + # + # @return The invited user or nil if the invitation failed. + def invite_user!(user) + user, token = user_invitation user + + if token + OpenProject::Notifications.send(EVENT_NAME, token) + + user + end + end + + ## + # Creates an invited user with the given email address. + # If no first and last is given it will default to 'OpenProject User' + # for the first name and 'To-be' for the last name. + # The default login is the email address. + # + # @return Returns the user and the invitation token required to register. + def user_invitation(user) + User.transaction do + if user.valid? + token = invitation_token user + token.save! + + user.invite + user.save! + + return [user, token] + end + end + + [user, nil] + end + + def token_action + 'invite' + end + + def invitation_token(user) + Token.find_or_initialize_by user: user, action: token_action + end +end diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb new file mode 100644 index 0000000000..4db12bd195 --- /dev/null +++ b/app/controllers/invitations_controller.rb @@ -0,0 +1,109 @@ +#-- 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 'uri' + +class InvitationsController < ApplicationController + + skip_before_filter :check_if_login_required, only: [:claim] + + def index + + end + + def create + email = params.require(:email) + user = User.create mail: email, login: email, firstname: email, lastname: email + token = invite_user user + + if user.errors.empty? + first, last = email.split("@") + user.firstname = first + user.lastname = "@#{last}" + user.invite + + user.save! + token.save! + + puts + puts "CREATED NEW TOKEN: #{token.value}" + puts + + redirect_to action: :show, id: user.id + else + flash.now[:error] = user.errors.full_messages.first + + render 'index', locals: { email: email } + end + end + + def show + user = User.find params.require(:id) + token = Token.find_by action: token_action, user: user + + render 'show', locals: { token: token.value, email: user.mail } + end + + def claim + token = Token.find_by action: token_action, value: params.require(:id) + + if current_user.logged? + flash[:warning] = 'You are already registered, mate.' + + redirect_to invitation_path id: token.user_id + elsif token.expired? + flash[:error] = 'The invitation has expired.' + token.destroy + + redirect_to signin_path + else + session[:invitation_token] = token.value + flash[:info] = 'Create a new account or register now, pl0x!' + + redirect_to signin_path + end + end + + module Functions + def token_action + 'invitation' + end + + def invite_user(user) + token = invitation_token user + + token + end + + def invitation_token(user) + Token.find_or_initialize_by user: user, action: token_action + end + end + + include Functions +end diff --git a/app/controllers/members_controller.rb b/app/controllers/members_controller.rb index abeaa2bd33..57b8eb79f1 100644 --- a/app/controllers/members_controller.rb +++ b/app/controllers/members_controller.rb @@ -35,6 +35,8 @@ class MembersController < ApplicationController before_filter :authorize include Pagination::Controller + include PaginationHelper + paginate_model User search_for User, :search_in_project search_options_for User, lambda { |_| { project: @project } } @@ -45,6 +47,15 @@ class MembersController < ApplicationController @@scripts.unshift(script) end + def index + @roles = Role.find_all_givable + @members = index_members @project + end + + def new + set_roles_and_principles! + end + def create if params[:member] members = new_members_from_params @@ -52,28 +63,37 @@ class MembersController < ApplicationController end respond_to do |format| if members.present? && members.all?(&:valid?) - flash.now.notice = l(:notice_successful_create) - - format.html do redirect_to settings_project_path(@project, tab: 'members') end + flash.notice = members_added_notice members - format.js do - @pagination_url_options = { controller: 'projects', action: 'settings', id: @project } - render(:update) do |page| - page.replace_html 'tab-content-members', partial: 'projects/settings/members', - locals: { members: members } - page.insert_html :top, 'tab-content-members', render_flash_messages + format.html do + redirect_to project_members_path(project_id: @project) + end - page << MembersController.tab_scripts + format.js + else + format.html do + if members.present? && params[:member] + @member = members.first + else + flash.error = l(:error_check_user_and_role) end + + set_roles_and_principles! + + render 'new' end - else + format.js do @pagination_url_options = { controller: 'projects', action: 'settings', id: @project } render(:update) do |page| if params[:member] - page.insert_html :top, 'tab-content-members', partial: 'members/member_errors', locals: { member: members.first } + page.replace_html 'new-member-message', + partial: 'members/member_errors', + locals: { member: members.first } else - page.insert_html :top, 'tab-content-members', partial: 'members/common_error', locals: { message: l(:error_check_user_and_role) } + page.replace_html 'new-member-message', + partial: 'members/common_error', + locals: { message: l(:error_check_user_and_role) } end end end @@ -84,45 +104,31 @@ class MembersController < ApplicationController def update member = update_member_from_params if member.save - flash.now.notice = l(:notice_successful_update) + flash[:notice] = l(:notice_successful_update) + else + # only possible message is about choosing at least one role + flash[:error] = member.errors.full_messages.first end - respond_to do |format| - format.html do redirect_to controller: '/projects', action: 'settings', tab: 'members', id: @project, page: params[:page] end - format.js do - @pagination_url_options = { controller: 'projects', action: 'settings', id: @project } - - render(:update) do |page| - if params[:membership] - @user = member.user - page.replace_html 'tab-content-memberships', partial: 'users/memberships' - else - page.replace_html 'tab-content-members', partial: 'projects/settings/members' - end - page.insert_html :top, 'tab-content-members', render_flash_messages - page << MembersController.tab_scripts - page.visual_effect(:highlight, "member-#{@member.id}") unless Member.find_by(id: @member.id).nil? - end - end - end + redirect_to project_members_path(project_id: @project, + page: params[:page], + per_page: params[:per_page]) end def destroy if @member.deletable? - @member.destroy - flash.now.notice = l(:notice_successful_delete) - end - respond_to do |format| - format.html do redirect_to controller: '/projects', action: 'settings', tab: 'members', id: @project end - format.js do - @pagination_url_options = { controller: 'projects', action: 'settings', id: @project } - render(:update) do |page| - page.replace_html 'tab-content-members', partial: 'projects/settings/members' - page.insert_html :top, 'tab-content-members', render_flash_messages - page << MembersController.tab_scripts - end + if @member.disposable? + flash.notice = I18n.t(:notice_member_deleted, user: @member.principal.name) + + @member.user.destroy + else + flash.notice = I18n.t(:notice_member_removed, user: @member.principal.name) + + @member.destroy end end + + redirect_to project_members_path(project_id: @project) end def autocomplete_for_member @@ -140,6 +146,10 @@ class MembersController < ApplicationController @principals = Principal.possible_members(params[:q], 100) - @project.principals end + @email = suggest_invite_via_email? current_user, + params[:q], + (@principals | @project.principals) + respond_to do |format| format.json format.html do @@ -158,28 +168,83 @@ class MembersController < ApplicationController private + def suggest_invite_via_email?(user, query, principals) + user.admin? && # only admins may add new users via email + query =~ mail_regex && + principals.none? { |p| p.mail == query } && + query # finally return email + end + + def mail_regex + /\A\S+@\S+\.\S+\z/ + end + + def index_members(project) + order = User::USER_FORMATS_STRUCTURE[Setting.user_format].map(&:to_s).join(', ') + + project + .member_principals + .includes(:roles, :principal, :member_roles) + .order(order) + .page(params[:page]) + .references(:users) + .per_page(per_page_param) + end + def self.tab_scripts @@scripts.join('(); ') + '();' end + def set_roles_and_principles! + @roles = Role.find_all_givable + # Check if there is at least one principal that can be added to the project + @principals_available = @project.possible_members('', 1) + end + def new_members_from_params - user_ids = possibly_seperated_ids_for_entity(params[:member], :user) roles = Role.where(id: possibly_seperated_ids_for_entity(params[:member], :role)) - new_member = lambda { |user_id| - Member.new(permitted_params.member).tap do |member| - member.user_id = user_id if user_id + if roles.present? + user_ids = invite_new_users possibly_seperated_ids_for_entity(params[:member], :user) + members = user_ids.map { |user_id| new_member user_id } + + # most likely wrong user input, use a dummy member for error handling + if !members.present? && roles.present? + members << new_member(nil) end - } - - members = user_ids.map { |user_id| - new_member.call(user_id) - } - # most likely wrong user input, use a dummy member for error handling - if !members.present? && roles.present? - members << new_member.call(nil) + + members + else + # Pick a user that exists but can't be chosen. + # We only want the missing role error message. + dummy = new_member User.anonymous.id + + [dummy] + end + end + + def new_member(user_id) + Member.new(permitted_params.member).tap do |member| + member.user_id = user_id if user_id end - members + end + + def invite_new_users(user_ids) + user_ids.map do |id| + if id.to_i == 0 && id.present? # we've got an email - invite that user + # only admins can invite new users + if current_user.admin? + # The invitation can pretty much only fail due to the user already + # having been invited. So look them up if it does. + user = UserInvitation.invite_new_user(email: id) || + User.find_by_mail(id) + + user.id if user + end + else + id + end + end.compact end def each_comma_seperated(array, &block) @@ -195,7 +260,7 @@ class MembersController < ApplicationController def transform_array_of_comma_seperated_ids(array) return array unless array.present? each_comma_seperated(array) do |elem| - elem.to_s.split(',').map(&:to_i) + elem.to_s.split(',') end end @@ -221,4 +286,12 @@ class MembersController < ApplicationController @member.assign_attributes(attrs) @member end + + def members_added_notice(members) + if members.size == 1 + l(:notice_member_added, name: members.first.name) + else + l(:notice_members_added, number: members.size) + end + end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 579273cb1f..59a7cc8922 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -120,26 +120,9 @@ class UsersController < ApplicationController @user = User.new(language: Setting.default_language, mail_notification: Setting.default_notification_option) @user.attributes = permitted_params.user_create_as_admin(false, @user.change_password_allowed?) @user.admin = params[:user][:admin] || false + @user.login = params[:user][:login] || @user.mail - if @user.change_password_allowed? - if params[:user][:assign_random_password] - @user.random_password! - else - @user.password = params[:user][:password] - @user.password_confirmation = params[:user][:password_confirmation] - end - end - - if @user.save - # TODO: Similar to My#account - @user.pref.attributes = params[:pref] || {} - @user.pref[:no_self_notified] = (params[:no_self_notified] == '1') - @user.pref.save - - @user.notified_project_ids = (@user.mail_notification == 'selected' ? params[:notified_project_ids] : []) - - UserMailer.account_information(@user, @user.password).deliver if params[:send_information] - + if UserInvitation.invite_user! @user respond_to do |format| format.html do flash[:notice] = l(:notice_successful_create) @@ -151,8 +134,6 @@ class UsersController < ApplicationController end else @auth_sources = AuthSource.all - # Clear password input - @user.password = @user.password_confirmation = nil respond_to do |format| format.html do render action: 'new' end @@ -187,8 +168,18 @@ class UsersController < ApplicationController @user.notified_project_ids = (@user.mail_notification == 'selected' ? params[:notified_project_ids] : []) - if @user.active? && params[:send_information] && !@user.password.blank? && @user.change_password_allowed? - UserMailer.account_information(@user, @user.password).deliver + if !@user.password.blank? && @user.change_password_allowed? + send_information = params[:send_information] + + if @user.invited? + # setting a password for an invited user activates them implicitly + @user.activate! + send_information = true + end + + if @user.active? && send_information + UserMailer.account_information(@user, @user.password).deliver + end end respond_to do |format| @@ -228,7 +219,12 @@ class UsersController < ApplicationController # Was the account activated? (do it before User#save clears the change) was_activated = (@user.status_change == [User::STATUSES[:registered], User::STATUSES[:active]]) - if @user.save + + if params[:activate] && @user.missing_authentication_method? + flash[:error] = I18n.t(:error_status_change_failed, + errors: I18n.t(:notice_user_missing_authentication_method), + scope: :user) + elsif @user.save flash[:notice] = I18n.t(:notice_successful_update) if was_activated UserMailer.account_activated(@user).deliver diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 038cd00c91..422d7f3dd6 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -52,12 +52,7 @@ module ProjectsHelper name: 'modules', action: :select_project_modules, partial: 'projects/settings/modules', - label: :label_module_plural }, - { - name: 'members', - action: :manage_members, - partial: 'projects/settings/members', - label: :label_member_plural + label: :label_module_plural }, { name: 'custom_fields', diff --git a/app/models/dummy_auth_source.rb b/app/models/dummy_auth_source.rb new file mode 100644 index 0000000000..a71aa03599 --- /dev/null +++ b/app/models/dummy_auth_source.rb @@ -0,0 +1,39 @@ +class DummyAuthSource < AuthSource + def test_connection + # the dummy connection is always available + end + + def authenticate(login, password) + existing_user(login, password) || on_the_fly_user(login) + end + + def auth_method_name + 'DerpLAP' + end + + private + + def existing_user(login, password) + registered_login?(login) && password == 'dummy' + end + + def on_the_fly_user(login) + return nil unless onthefly_register? + + { + firstname: login.capitalize, + lastname: 'Dummy', + mail: 'login@DerpLAP.net', + auth_source_id: id + } + end + + def registered_login?(login) + not users.where(login: login).empty? # empty? to use EXISTS query + end + + # Does this auth source backend allow password changes? + def self.allow_password_changes? + false + end +end diff --git a/app/models/member.rb b/app/models/member.rb index 2d24a573b8..ac1262a83f 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -46,7 +46,7 @@ class Member < ActiveRecord::Base after_destroy :unwatch_from_permission_change def name - user.name + principal.name end def to_s @@ -130,6 +130,14 @@ class Member < ActiveRecord::Base @membership end + ## + # Returns true if this user can be deleted as they have no other memberships + # and haven't been activated yet. Only applies if the member is actually a user + # as opposed to a group. + def disposable? + user && user.invited? && user.memberships.none? { |m| m.project_id != project_id } + end + protected def destroy_if_no_roles_left! diff --git a/app/models/principal.rb b/app/models/principal.rb index 8a9c071a7d..68dd6a9a22 100644 --- a/app/models/principal.rb +++ b/app/models/principal.rb @@ -36,7 +36,8 @@ class Principal < ActiveRecord::Base builtin: 0, active: 1, registered: 2, - locked: 3 + locked: 3, + invited: 4 } self.table_name = "#{table_name_prefix}users#{table_name_suffix}" @@ -55,7 +56,9 @@ class Principal < ActiveRecord::Base scope :active, -> { where(status: STATUSES[:active]) } - scope :active_or_registered, -> { where(status: [STATUSES[:active], STATUSES[:registered]]) } + scope :active_or_registered, -> { + where(status: [STATUSES[:active], STATUSES[:registered], STATUSES[:invited]]) + } scope :active_or_registered_like, ->(query) { active_or_registered.like(query) } diff --git a/app/models/project.rb b/app/models/project.rb index 808cea306a..1221572ca2 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -68,7 +68,8 @@ class Project < ActiveRecord::Base .where("#{Principal.table_name}.type='Group' OR " + "(#{Principal.table_name}.type='User' AND " + "(#{Principal.table_name}.status=#{Principal::STATUSES[:active]} OR " + - "#{Principal.table_name}.status=#{Principal::STATUSES[:registered]}))") + "#{Principal.table_name}.status=#{Principal::STATUSES[:registered]} OR " + + "#{Principal.table_name}.status=#{Principal::STATUSES[:invited]}))") }, class_name: 'Member' has_many :users, through: :members has_many :principals, through: :member_principals, source: :principal diff --git a/app/models/user.rb b/app/models/user.rb index 670f4e9f7d..5f65ff6cb2 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -215,12 +215,12 @@ class User < Principal register_allowance_evaluator OpenProject::PrincipalAllowanceEvaluator::Default # Returns the user that matches provided login and password, or nil - def self.try_to_login(login, password) + def self.try_to_login(login, password, session = nil) # Make sure no one can sign in with an empty password return nil if password.to_s.empty? user = find_by_login(login) user = if user - try_authentication_for_existing_user(user, password) + try_authentication_for_existing_user(user, password, session) else try_authentication_and_create_user(login, password) end @@ -233,8 +233,11 @@ class User < Principal # Tries to authenticate a user in the database via external auth source # or password stored in the database - def self.try_authentication_for_existing_user(user, password) + def self.try_authentication_for_existing_user(user, password, session = nil) + activate_user! user, session if session + return nil if !user.active? || OpenProject::Configuration.disable_password_login? + if user.auth_source # user has an external authentication method return nil unless user.auth_source.authenticate(user.login, password) @@ -247,6 +250,19 @@ class User < Principal user end + def self.activate_user!(user, session) + if session[:invitation_token] + token = Token.find_by_value session[:invitation_token] + invited_id = token && token.user.id + + if user.id == invited_id + user.activate! + token.destroy! + session.delete :invitation_token + end + end + end + # Tries to authenticate with available sources and creates user on success def self.try_authentication_and_create_user(login, password) return nil if OpenProject::Configuration.disable_password_login? @@ -328,6 +344,10 @@ class User < Principal self.status = STATUSES[:registered] end + def invite + self.status = STATUSES[:invited] + end + def lock self.status = STATUSES[:locked] end @@ -340,6 +360,14 @@ class User < Principal update_attribute(:status, STATUSES[:registered]) end + def invite! + update_attribute(:status, STATUSES[:invited]) + end + + def invited? + status == STATUSES[:invited] + end + def lock! update_attribute(:status, STATUSES[:locked]) end @@ -707,6 +735,17 @@ class User < Principal User.current.admin? ? Role.all : User.current.roles_for_project(project) end + ## + # Returns true if no authentication method has been chosen for this user yet. + # There are three possible methods currently: + # + # - username & password + # - OmniAuth + # - LDAP + def missing_authentication_method? + identity_url.nil? && passwords.empty? && auth_source.nil? + end + # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only # one anonymous user per database. def self.anonymous diff --git a/app/views/account/_auth_providers.html.erb b/app/views/account/_auth_providers.html.erb index 2d01b1323c..e97779a066 100644 --- a/app/views/account/_auth_providers.html.erb +++ b/app/views/account/_auth_providers.html.erb @@ -39,12 +39,13 @@ See doc/COPYRIGHT.rdoc for more details. auth_provider_html = call_hook :view_account_login_auth_provider no_pwd = OpenProject::Configuration.disable_password_login? pclass = no_pwd ? 'no-pwd' : '' +wclass = local_assigns[:wide] ? 'wide' : '' %> <% if auth_provider_html.strip != '' %> -
+
+
+
+
+ <%= User.model_name.human %> / <%= Group.model_name.human %>
+
+
+ |
+
+
+
+
+
+ <%= l(:label_role_plural) %>
+
+
+ |
+ <%= call_hook(:view_projects_settings_members_table_header, :project => @project) %>
+ + |
---|---|---|
+ <%= link_to_user member.principal %> + <% if member.user && member.user.invited? %> + + <% end %> + | +
+ <%=h member.roles.sort.collect(&:to_s).join(', ') %>
+ <% if authorized %>
+ <%= form_for(member, :url => {:controller => '/members',
+ :action => 'update',
+ :id => member,
+ :page => params[:page],
+ :per_page => params[:per_page] },
+ :method => :put,
+ :html => { :id => "member-#{member.id}-roles-form",
+ :class => 'hol',
+ :style => 'display:none' }) do |f| %>
+ <% @roles.each do |role| %> + + <% end %> + <%= hidden_field_tag 'member[role_ids][]', '' %> +<%= submit_tag l(:button_change), :class => 'button -highlight -small' %> + <%= link_to_function l(:button_cancel), + "$('member-#{member.id}-roles').show(); $('member-#{member.id}-roles-form').hide(); return false;", + class: 'button -small' %> + <% end %> + <%= call_hook(:view_projects_settings_members_table_row, { :project => @project, :member => member}) %> + | + <% + delete_class, delete_title = if member.disposable? + ['icon icon-delete', I18n.t(:title_remove_and_delete_user)] + else + ['icon icon-close', I18n.t(:button_remove)] + end + %> + <%= link_to_function '', "$('member-#{member.id}-roles').hide(); $('member-#{member.id}-roles-form').show(); return false;", :class => 'icon icon-edit', :title => l(:button_edit) %> + <%= link_to('', {:controller => '/members', :action => 'destroy', :id => member, :page => params[:page]}, + :method => :delete, + data: { confirm: ((!User.current.admin? && member.include?(User.current)) ? l(:text_own_membership_delete_confirmation) : nil) }, + :title => delete_title, :class => delete_class) if member.deletable? %> + | + <% end %> + +
<%= l(:label_no_data) %>
++ <%= I18n.t('text_no_roles_defined') %> +
+ <%= link_to I18n.t('button_back'), :back, class: 'button' %> + <% end %> +
-
-
-
-
- <%= User.model_name.human %> / <%= Group.model_name.human %>
-
-
- |
-
-
-
-
-
- <%= l(:label_role_plural) %>
-
-
- |
- <%= call_hook(:view_projects_settings_members_table_header, :project => @project) %>
- - |
---|---|---|
<%= link_to_user member.principal %> | -
- <%=h member.roles.sort.collect(&:to_s).join(', ') %>
- <% if authorized %>
- <%= form_for(member, :url => {:controller => '/members',
- :action => 'update',
- :id => member,
- :page => params[:page]},
- :method => :put,
- :remote => true,
- :html => { :id => "member-#{member.id}-roles-form",
- :class => 'hol',
- :style => 'display:none' }) do |f| %>
- <% roles.each do |role| %> - - <% end %> - <%= hidden_field_tag 'member[role_ids][]', '' %> -<%= submit_tag l(:button_change), :class => 'button -highlight -small' %> - <%= link_to_function l(:button_cancel), - "$('member-#{member.id}-roles').show(); $('member-#{member.id}-roles-form').hide(); return false;", - class: 'button -small' %> - <% end %> - <%= call_hook(:view_projects_settings_members_table_row, { :project => @project, :member => member}) %> - | - <%= link_to_function l(:button_edit), "$('member-#{member.id}-roles').hide(); $('member-#{member.id}-roles-form').show(); return false;", :class => 'icon icon-edit' %> - <%= link_to(l(:button_delete), {:controller => '/members', :action => 'destroy', :id => member, :page => params[:page]}, - :method => :delete, - :remote => true, - data: { confirm: ((!User.current.admin? && member.include?(User.current)) ? l(:text_own_membership_delete_confirmation) : nil) }, - :title => l(:button_delete), :class => 'icon icon-delete') if member.deletable? %> - | - <% end %> - -
<%= l(:label_no_data) %>
-<%= styled_button_tag l(:button_create), class: '-highlight -with-icon icon-yes' %> <%= styled_button_tag l(:button_create_and_continue), :name => 'continue', class: '-highlight -with-icon icon-yes' %> diff --git a/config/initializers/menus.rb b/config/initializers/menus.rb index 870970375d..79345d7a82 100644 --- a/config/initializers/menus.rb +++ b/config/initializers/menus.rb @@ -267,6 +267,12 @@ Redmine::MenuManager.map :project_menu do |menu| if: Proc.new { |p| p.project_type.try :allows_association }, html: { class: 'icon2 icon-dependency' } + menu.push :members, + { controller: :members, action: :index }, + param: :project_id, + caption: :label_member_plural, + html: { class: 'icon2 icon-group' } + menu.push :settings, { controller: '/projects', action: 'settings' }, caption: :label_project_settings, diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index 2320c3d210..43e948b246 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -54,8 +54,11 @@ Redmine::AccessControl.map do |map| require: :member map.permission :manage_members, - { projects: :settings, - members: [:create, :update, :destroy, :autocomplete_for_member] }, + { members: [:index, :new, :create, :update, :destroy, :autocomplete_for_member] }, + require: :member + + map.permission :view_members, + { members: [:index] }, require: :member map.permission :manage_versions, diff --git a/config/initializers/user_invitation.rb b/config/initializers/user_invitation.rb new file mode 100644 index 0000000000..f275be7d7a --- /dev/null +++ b/config/initializers/user_invitation.rb @@ -0,0 +1,6 @@ +## +# The default behaviour is to send the user a sign-up mail +# when they were invited. +OpenProject::Notifications.subscribe UserInvitation::EVENT_NAME do |token| + UserMailer.user_signed_up(token).deliver +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 2872034787..6df8f4212d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -27,14 +27,15 @@ #++ en: + label_invitation: Invitation account: delete: "Delete account" delete_confirmation: "Are you sure you want to delete the account?" deleted: "Account successfully deleted" deletion_info: data_consequences: - other: "Of the data the user created (e.g. e-mail, preferences, work packages, wiki entries) as much as possible will be deleted. Note however, that data like work packages and wiki entries can not be deleted without impeding the work of the other users. Such data is hence reassigned to an account called \"Deleted user\". As the data of every deleted account is reassigned to this account it will not be possible to distinguish the data the user created from the data of another deleted account." - self: "Of the data you created (e.g. e-mail, preferences, work packages, wiki entries) as much as possible will be deleted. Note however, that data like work packages and wiki entries can not be deleted without impeding the work of the other users. Such data is hence reassigned to an account called \"Deleted user\". As the data of every deleted account is reassigned to this account it will not be possible to distinguish the data you created from the data of another deleted account." + other: "Of the data the user created (e.g. email, preferences, work packages, wiki entries) as much as possible will be deleted. Note however, that data like work packages and wiki entries can not be deleted without impeding the work of the other users. Such data is hence reassigned to an account called \"Deleted user\". As the data of every deleted account is reassigned to this account it will not be possible to distinguish the data the user created from the data of another deleted account." + self: "Of the data you created (e.g. email, preferences, work packages, wiki entries) as much as possible will be deleted. Note however, that data like work packages and wiki entries can not be deleted without impeding the work of the other users. Such data is hence reassigned to an account called \"Deleted user\". As the data of every deleted account is reassigned to this account it will not be possible to distinguish the data you created from the data of another deleted account." heading: "Delete account %{name}" info: other: "Deleting the user account is an irreversible action." @@ -52,6 +53,10 @@ en: User registration is disabled on this system. Please ask an administrator to create an account for you. login_with_auth_provider: "or sign in with your existing account" + signup_with_auth_provider: "or sign up using" + signup_with_password: "using user name and password." + auth_source_login: Please login as %{login} to activate your account. + omniauth_login: Please login to activate your account. actionview_instancetag_blank_option: "Please select" @@ -356,6 +361,7 @@ en: work_package: "Work package" button_add: "Add" + button_add_member: Add member button_add_watcher: "Add watcher" button_annotate: "Annotate" button_apply: "Apply" @@ -387,6 +393,7 @@ en: button_move: "Move" button_move_and_follow: "Move and follow" button_quote: "Quote" + button_remove: Remove button_remove_widget: "Remove widget" button_rename: "Rename" button_reply: "Reply" @@ -937,7 +944,8 @@ en: label_preview: "Preview" label_previous: "Previous" label_previous_week: "Previous week" - label_principal_search: "Search for users or groups" + label_principal_invite_via_email: " or invite new users via email" + label_principal_search: "Add existing users or groups" label_professional_support: "Professional support" label_profile: "Profile" label_project_all: "All Projects" @@ -993,7 +1001,7 @@ en: label_role_and_permissions: "Roles and permissions" label_role_new: "New role" label_role_plural: "Roles" - label_role_search: "Search for roles" + label_role_search: "Assign roles to new members" label_scm: "SCM" label_search: "Search" label_search_titles_only: "Search titles only" @@ -1173,6 +1181,8 @@ en: noscript_learn_more: "Learn more" notice_account_activated: "Your account has been activated. You can now log in." + notice_account_already_activated: The account has already been activated. + notice_account_invalid_token: Invalid activation token notice_account_invalid_credentials: "Invalid user or password" notice_account_invalid_credentials_or_blocked: "Invalid user or password or the account is blocked due to multiple failed login attempts. If so, it will be unblocked automatically in a short time." notice_account_lost_email_sent: "An email with instructions to choose a new password has been sent to you." @@ -1186,6 +1196,7 @@ en: notice_account_updated: "Account was successfully updated." notice_account_wrong_password: "Wrong password" notice_account_registered_and_logged_in: "Welcome, your account has been activated. You are logged in now." + notice_activation_failed: The account could not be activated. notice_api_access_key_reseted: "Your API access key was reset." notice_can_t_change_password: "This account uses an external authentication source. Impossible to change the password." notice_email_error: "An error occurred while sending mail (%{value})" @@ -1200,6 +1211,10 @@ en: notice_locking_conflict: "Information has been updated by at least one other user in the meantime." notice_locking_conflict_additional_information: "The update(s) came from %{users}." notice_locking_conflict_reload_page: "Please reload the page, review the changes and reapply your updates." + notice_member_added: Added %{name} to the project. + notice_members_added: Added %{number} users to the the project. + notice_member_removed: "Removed %{user} from project." + notice_member_deleted: "%{user} has been removed from the project and deleted." notice_no_principals_found: "No results found." notice_bad_request: "Bad Request." notice_not_authorized: "You are not authorized to access this page." @@ -1212,8 +1227,10 @@ en: notice_to_many_principals_to_display: "There are too many results.\nNarrow down the search by typing in the name of the new member (or group)." 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_automatic_set_of_standard_type: "Set standard type automatically." notice_logged_out: "You have been logged out." + notice_wont_delete_auth_source: The authentication mode cannot be deleted as long as there are still users using it. error_types_in_use_by_work_packages: "The following types are still referenced by work packages: %{types}" notice_project_cannot_update_custom_fields: "You cannot update the project's available custom fields." @@ -1514,6 +1531,8 @@ en: show_hide_project_menu: "Show/Hide project menu" status_active: "active" + status_invited: invited + status_registered: registered # Used in array.to_sentence. support: @@ -1563,6 +1582,7 @@ en: text_line_separated: "Multiple values allowed (one line for each value)." text_load_default_configuration: "Load the default configuration" text_min_max_length_info: "0 means no restriction" + text_no_roles_defined: There are no roles defined. text_no_configuration_data: "Roles, types, work package statuses and workflow have not been configured yet.\nIt is highly recommended to load the default configuration. You will be able to modify it once loaded." text_no_notes: "There are no comments available for this work package." text_notice_too_many_values_are_inperformant: "Note: Displaying more than 100 items per page can increase the page load time." @@ -1585,6 +1605,7 @@ en: text_tip_work_package_end_day: "work package ending this day" text_type_no_workflow: "No workflow defined for this type" text_unallowed_characters: "Unallowed characters" + text_user_invited: The user has been invited and is pending registration. text_user_mail_option: "For unselected projects, you will only receive notifications about things you watch or you're involved in (eg. work packages you're the author or assignee)." text_user_wrote: "%{value} wrote:" text_warn_on_leaving_unsaved: "The current page contains unsaved text that will be lost if you leave this page." @@ -1796,6 +1817,8 @@ en: quarters: "Quarters" years: "Years" + title_remove_and_delete_user: Remove the invited user from the project and delete them. + queries: apply_filter: Apply preconfigured filter @@ -1812,7 +1835,7 @@ en: authentication_settings_disabled_due_to_external_authentication: > This user authenticates via an external authentication provider, so there is no password in OpenProject to be changed. - assign_random_password: "Assign random password (sent to user via e-mail)" + assign_random_password: "Assign random password (sent to user via email)" blocked: "locked temporarily" blocked_num_failed_logins: one: "locked temporarily (one failed login attempt)" @@ -1822,6 +1845,7 @@ en: lock: "Lock permanently" locked: "locked permanently" registered: "registered" + invited: invited reset_failed_logins: "Reset failed logins" status_user_and_brute_force: "%{user} and %{brute_force}" unlock: "Unlock" @@ -1829,6 +1853,7 @@ en: no_login: "This user authenticates through login by password. Since it is disabled, they cannot log in." password_change_unsupported: Change of password is not supported. authorization_rejected: "You are not allowed to sign in." + invite: Invite user via email version_status_closed: "closed" version_status_locked: "locked" diff --git a/config/routes.rb b/config/routes.rb index d2d0cb2fcd..690f82868b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -45,7 +45,7 @@ OpenProject::Application.routes.draw do get '/account/force_password_change', action: 'force_password_change' post '/account/change_password', action: 'change_password' match '/account/lost_password', action: 'lost_password', via: [:get, :post] - match '/account/register', action: 'register', via: [:get, :post] + match '/account/register', action: 'register', via: [:get, :post, :patch] # omniauth routes match '/auth/:provider/callback', action: 'omniauth_login', @@ -329,7 +329,7 @@ OpenProject::Application.routes.draw do resources :categories, except: [:index, :show], shallow: true - resources :members, only: [:create, :update, :destroy], shallow: true do + resources :members, only: [:index, :new, :create, :update, :destroy], shallow: true do match :autocomplete_for_member, on: :collection, via: [:get, :post] end @@ -578,6 +578,11 @@ OpenProject::Application.routes.draw do resources :reported_project_statuses, controller: 'reported_project_statuses' + resources :invitations, controller: 'invitations', only: [:index, :show, :create] + scope controller: 'invitations' do + get 'claim/:id', action: 'claim' + end + # This route should probably be removed, but it's used at least by one cuke and we don't # want to break it. # This route intentionally occurs after the admin/roles/new route, so that one takes diff --git a/db/seeds/roles.rb b/db/seeds/roles.rb index db5b09b1d7..e25466a5a6 100644 --- a/db/seeds/roles.rb +++ b/db/seeds/roles.rb @@ -84,7 +84,8 @@ else :browse_repository, :view_changesets, :commit_access, - :view_commit_author_statistics] + :view_commit_author_statistics, + :view_members] end.save! Role.new.tap do |reader| diff --git a/features/groups/group_memberships.feature b/features/groups/group_memberships.feature deleted file mode 100644 index 3d666bb2db..0000000000 --- a/features/groups/group_memberships.feature +++ /dev/null @@ -1,186 +0,0 @@ -#-- 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. -#++ - -Feature: Group Memberships - - Background: - Given I am already admin - - Given there is a role "Manager" - And there is a role "Developer" - - And there is 1 project with the following: - | Name | Project1 | - | Identifier | project1 | - - And there is 1 User with: - | Login | peter | - | Firstname | Peter | - | Lastname | Pan | - - And there is 1 User with: - | Login | hannibal | - | Firstname | Hannibal | - | Lastname | Smith | - - And there is a group named "A-Team" with the following members: - | peter | - | hannibal | - - - @javascript - Scenario: Adding a group to a project on the project's page adds the group members as well - When I go to the settings page of the project called "Project1" - And I click on "tab-members" - And I add the principal "A-Team" as a member with the roles: - | Manager | - Then I should be on the "members" tab of the settings page of the project called "Project1" - And I should see "A-Team" within ".generic-table" - And I should see "Hannibal Smith" within ".generic-table" - And I should see "Peter Pan" within ".generic-table" - - @javascript - Scenario: Group-based memberships and individual memberships are handled separately - When I go to the settings page of the project called "Project1" - And I click on "tab-members" - And I add the principal "Hannibal Smith" as a member with the roles: - | Manager | - And I wait for the AJAX requests to finish - Then I should see "Successful creation." within ".flash.notice" - - And I add the principal "A-Team" as a member with the roles: - | Developer | - And I wait for the AJAX requests to finish - Then I should see "Successful creation." within ".flash.notice" - - When I delete the "A-Team" membership - And I wait for the AJAX requests to finish - - Then I should see "Hannibal Smith" within ".generic-table" - And I should not see "A-Team" within ".generic-table" - And I should not see "Peter Pan" within ".generic-table" - - - @javascript - Scenario: Removing a group from a project on the project's page removes all group members as well - When I go to the settings page of the project called "Project1" - And I click on "tab-members" - And I add the principal "A-Team" as a member with the roles: - | Manager | - - Then I should be on the "members" tab of the settings page of the project called "Project1" - And I wait for the AJAX requests to finish - - When I delete the "A-Team" membership - And I wait for the AJAX requests to finish - - Then I should see "Nothing to display" - And I should not see "A-Team" within ".generic-table--container" - And I should not see "Hannibal Smith" within ".generic-table--container" - And I should not see "Peter Pan" within ".generic-table--container" - - @javascript - Scenario: Adding a user to a group adds the user to projects as well - When I go to the admin page of the group called "A-Team" - And I click on "tab-users" - And I delete "hannibal" from the group - And I wait for the AJAX requests to finish - - And I click on "tab-memberships" - And I select "Project1" from "Projects" - And I check "Manager" - And I press "Add" within "#tab-content-memberships" - And I wait for the AJAX requests to finish - - And I click on "tab-users" - And I check "Hannibal Smith" - And I press "Add" within "#tab-content-users" - And I wait for the AJAX requests to finish - - When I go to the settings page of the project called "Project1" - And I click on "tab-members" - - Then I should see "A-Team" within ".generic-table" - And I should see "Peter Pan" within ".generic-table" - And I should see "Hannibal Smith" within ".generic-table" - - - @javascript - Scenario: Removing a user from a group removes the user from projects as well - When I go to the admin page of the group called "A-Team" - And I click on "tab-memberships" - And I select "Project1" from "Projects" - And I check "Manager" - And I press "Add" within "#tab-content-memberships" - And I wait for the AJAX requests to finish - - When I click on "tab-users" - And I delete "hannibal" from the group - And I wait for the AJAX requests to finish - - When I go to the settings page of the project called "Project1" - And I click on "tab-members" - - Then I should see "A-Team" within ".generic-table" - And I should not see "Hannibal Smith" within ".generic-table" - And I should see "Peter Pan" within ".generic-table" - - @javascript - Scenario: Adding a group to project on the group's page adds the group members as well - When I go to the admin page of the group called "A-Team" - And I click on "tab-memberships" - And I select "Project1" from "Projects" - And I check "Manager" - And I press "Add" within "#tab-content-memberships" - And I wait for the AJAX requests to finish - - Then the project member "A-Team" should have the role "Manager" - - When I go to the settings page of the project called "Project1" - And I click on "tab-members" - - Then I should see "A-Team" within ".generic-table" - And I should see "Hannibal Smith" within ".generic-table" - And I should see "Peter Pan" within ".generic-table" - - @javascript - Scenario: Adding/Removing a group to/from a project displays success message - When I go to the admin page of the group called "A-Team" - And I click on "tab-memberships" - And I select "Project1" from "Projects" - And I check "Manager" - And I press "Add" within "#tab-content-memberships" - And I wait for the AJAX requests to finish - - Then I should see "Successful update." within ".notice" - And I should see "Project1" - - When I follow "Delete" within "table.generic-table.memberships" - - Then I should see "Successful deletion." within ".notice" - And I should see "No data to display" diff --git a/features/groups/membership.feature b/features/groups/membership.feature deleted file mode 100644 index 65602a7343..0000000000 --- a/features/groups/membership.feature +++ /dev/null @@ -1,99 +0,0 @@ -#-- 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. -#++ - -Feature: Group memberships - Background: - Given there is 1 project with the following: - | name | project1 | - | identifier | project1 | - And there is 1 user with the following: - | login | bob | - | firstname | Bob | - | Lastname | Bobbit | - And there is 1 user with the following: - | login | alice | - | firstname | Alice | - | lastname | Wonderland | - And there is 1 group with the following: - | name | group1 | - And there is a role "alpha" - And there is a role "beta" - And the role "alpha" may have the following rights: - | manage_members | - And the user "bob" is a "alpha" in the project "project1" - - @javascript - Scenario: Adding a group with members to a project - Given the group "group1" has the following members: - | alice | - And I am already logged in as "bob" - When I go to the members tab of the settings page of the project "project1" - And I add the principal "group1" as a member with the roles: - | beta | - Then I should see the principal "group1" as a member with the roles: - | beta | - And I should see the principal "alice" as a member with the roles: - | beta | - - Scenario: Adding members to a group after the group has been added to the project adds the users to the project - Given the group "group1" is a "beta" in the project "project1" - And I am already admin - When I go to the edit page of the group called "group1" - And I follow "Users" within ".tabs" - And I add the user "alice" to the group - And I go to the members tab of the settings page of the project "project1" - Then I should see the principal "group1" as a member with the roles: - | beta | - And I should see the principal "alice" as a member with the roles: - | beta | - - @javascript - Scenario: Removing a group from a project removes its members (users) as well if they have no roles of their own - Given the group "group1" has the following members: - | alice | - And the group "group1" is a "beta" in the project "project1" - And I am already logged in as "bob" - When I go to the members tab of the settings page of the project "project1" - And I follow the delete link of the project member "group1" - Then I should not see the principal "group1" as a member - And I should not see the principal "alice" as a member - - @javascript - Scenario: Removing a group from a project leaves a member if he has other roles besides those inherited from the group - Given the group "group1" has the following members: - | alice | - And the user "alice" is a "alpha" in the project "project1" - And the group "group1" is a "beta" in the project "project1" - And I am already logged in as "bob" - When I go to the members tab of the settings page of the project "project1" - And I follow the delete link of the project member "group1" - Then I should not see the principal "group1" as a member - And I should see the principal "alice" as a member with the roles: - | alpha | - - diff --git a/features/members/error_messages.feature b/features/members/error_messages.feature deleted file mode 100644 index cc0fbb51b7..0000000000 --- a/features/members/error_messages.feature +++ /dev/null @@ -1,114 +0,0 @@ -#-- 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. -#++ - -Feature: Error Messages - - Background: - Given I am already admin - - Given there is a role "Manager" - - And there is 1 project with the following: - | Name | Project1 | - | Identifier | project1 | - - And there is 1 User with: - | Login | peter | - | Firstname | Peter | - | Lastname | Pan | - - @javascript - Scenario: Adding a Principal, non impaired - When I go to the settings page of the project called "Project1" - And I click on "tab-members" - And I select the principal "Peter Pan" - And I click on "Add" within "#tab-content-members" - And I wait for AJAX - Then I should see 1 error message - And I click on "Add" within "#tab-content-members" - And I wait for AJAX - Then I should not see 2 error messages - - @javascript - Scenario: Adding a Role, non impaired - When I go to the settings page of the project called "Project1" - And I click on "tab-members" - And I select the role "Manager" - And I click on "Add" within "#tab-content-members" - And I wait for AJAX - Then I should see 1 error message - And I click on "Add" within "#tab-content-members" - And I wait for AJAX - Then I should not see 2 error messages - - @javascript - Scenario: Adding a Principal, impaired - When I am impaired - And I go to the settings page of the project called "Project1" - And I click on "tab-members" - And I select the principal "Peter Pan" - And I click on "Add" within "#tab-content-members" - And I wait for AJAX - Then I should see 1 error message - And I click on "Add" within "#tab-content-members" - And I wait for AJAX - Then I should not see 2 error messages - - @javascript - Scenario: Adding a Role, impaired - When I am impaired - And I go to the settings page of the project called "Project1" - And I click on "tab-members" - And I select the role "Manager" - And I click on "Add" within "#tab-content-members" - And I wait for AJAX - Then I should see 1 error message - And I click on "Add" within "#tab-content-members" - And I wait for AJAX - Then I should not see 2 error messages - - @javascript - Scenario: Removing old error or success messages when adding members - Given there is 1 User with: - | Login | tinkerbell | - | Firstname | Tinker | - | Lastname | Bell | - When I go to the settings page of the project called "Project1" - And I click on "tab-members" - And I select the principal "Peter Pan" - And I select the role "Manager" - And I click on "Add" within "#tab-content-members" - And I wait for AJAX - Then there should be a flash notice message - And there should not be any error message - - Then I select the principal "Tinker Bell" - And I click on "Add" within "#tab-content-members" - And I wait for AJAX - Then I should see 1 error message - And there should not be a flash notice message diff --git a/features/members/membership.feature b/features/members/membership.feature deleted file mode 100644 index 2846a5d7e3..0000000000 --- a/features/members/membership.feature +++ /dev/null @@ -1,154 +0,0 @@ -#-- 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. -#++ - -Feature: Membership - - Background: - Given I am already admin - - Given there is a role "Manager" - And there is a role "Developer" - - And there is 1 project with the following: - | Identifier | project1 | - - And there is 1 User with: - | Login | peter | - | Firstname | Peter | - | Lastname | Pan | - - And there is 1 User with: - | Login | hannibal | - | Firstname | Hannibal | - | Lastname | Smith | - - And there is 1 User with: - | Login | crash | - | Firstname | | - | Lastname | | - - And there is a group named "A-Team" with the following members: - | peter | - | hannibal | - - @javascript - Scenario: Adding and Removing a Group as Member, non impaired - When I go to the members tab of the settings page of the project "project1" - And I add the principal "A-Team" as "Manager" - Then I should be on the members tab of the settings page of the project "project1" - And I should see "Successful creation." within ".flash.notice" - And I should see "A-Team" within ".generic-table" - - When I delete the "A-Team" membership - And I wait for the AJAX requests to finish - Then I should see "Nothing to display" - - @javascript - Scenario: Adding and removing a User as Member, non impaired - When I go to the members tab of the settings page of the project "project1" - And I add the principal "Hannibal Smith" as "Manager" - Then I should see "Successful creation." within ".flash.notice" - And I should see "Hannibal Smith" within ".generic-table" - - When I delete the "Hannibal Smith" membership - And I wait for the AJAX requests to finish - Then I should see "Nothing to display" - - @javascript - Scenario: Entering a Username as Member in firstname, lastname order, non impaired - When I go to the members tab of the settings page of the project "project1" - And I enter the principal name "Hannibal S" - Then I should see "Hannibal Smith" - - @javascript - Scenario: Entering a Username as Member in lastname, firstname order, non impaired - When I go to the members tab of the settings page of the project "project1" - And I enter the principal name "Smith, H" - Then I should see "Hannibal Smith" - - @javascript - Scenario: Escaping should work properly when entering a name - When I go to the members tab of the settings page of the project "project1" - And I enter the principal name "script" - Then I should not see an alert dialog - And I should see "" - - @javascript - Scenario: Escaping should work properly when selecting a user - When I go to the members tab of the settings page of the project "project1" - When I select the principal "script" - Then I should not see an alert dialog - And I should see "" - - @javascript - Scenario: Adding and Removing a Group as Member, impaired - When I am impaired - And I go to the members tab of the settings page of the project "project1" - And I add the principal "A-Team" as "Manager" - And I go to the members tab of the settings page of the project "project1" - Then I should not see "A-Team" within "#principal_results" - And I should see "A-Team" within ".generic-table" - - @javascript - Scenario: User should not appear in members form if he/she is already a member of the project, impaired - When I am impaired - And I go to the members tab of the settings page of the project "project1" - And I add the principal "A-Team" as "Manager" - Then I should be on the members tab of the settings page of the project "project1" - And I should see "Successful creation." within ".flash.notice" - And I should see "A-Team" within ".generic-table" - - When I delete the "A-Team" membership - And I wait for the AJAX requests to finish - Then I should see "Nothing to display" - - @javascript - Scenario: Entering a Username as Member in firstname, lastname order, impaired - When I am impaired - And I go to the members tab of the settings page of the project "project1" - And I enter the principal name "Hannibal S" - Then I should see "Hannibal Smith" - - @javascript - Scenario: Entering a Username as Member in lastname, firstname order, impaired - When I am impaired - And I go to the members tab of the settings page of the project "project1" - And I enter the principal name "Smith, H" - Then I should see "Hannibal Smith" - - @javascript - Scenario: Adding and removing a User as Member, impaired - When I am impaired - And I go to the members tab of the settings page of the project "project1" - And I add the principal "Hannibal Smith" as "Manager" - Then I should see "Successful creation." within ".flash.notice" - And I should see "Hannibal Smith" within ".generic-table" - - When I delete the "Hannibal Smith" membership - And I wait for the AJAX requests to finish - Then I should see "Nothing to display" diff --git a/features/members/pagination.feature b/features/members/pagination.feature deleted file mode 100644 index 283b349159..0000000000 --- a/features/members/pagination.feature +++ /dev/null @@ -1,79 +0,0 @@ -#-- 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. -#++ - -Feature: Membership - - Background: - Given I am already admin - - Given there is a role "Manager" - And there is a role "Developer" - And there is 1 project with the following: - | Identifier | project1 | - And there is 1 User with: - | Login | peter | - | Firstname | Peter | - | Lastname | Pan | - And there is 1 user with the following: - | login | bob | - | firstname | Bob | - | Lastname | Bobbit | - And there is 1 user with the following: - | login | alice | - | firstname | Alice | - | Lastname | Alison | - And the user "bob" is a "Manager" in the project "project1" - And the user "alice" is a "Developer" in the project "project1" - - @javascript - Scenario: Paginating after adding a member - Given we paginate after 2 items - When I go to the members tab of the settings page of the project "project1" - And I add the principal "peter" as "Manager" - When I follow "2" within ".pagination" - Then I should see "Peter Pan" - - @javascript - Scenario: Paginating after removing a member - Given we paginate after 1 items - And the user "peter" is a "Manager" in the project "project1" - When I go to the members tab of the settings page of the project "project1" - And I delete the "Alice Alison" membership - Then I should see "Bob Bobbit" - When I follow "2" within ".pagination" - Then I should see "Peter Pan" - - @javascript - Scenario: Paginating after updating a member - Given we paginate after 1 items - When I go to the members tab of the settings page of the project "project1" - And I click on "Edit" - And I check "Manager" - And I click "Change" - And I follow "2" within ".pagination" - Then I should see "Bob Bobbit" diff --git a/features/projects/settings.feature b/features/projects/settings.feature index bc0b0950d1..7d9a858fdd 100644 --- a/features/projects/settings.feature +++ b/features/projects/settings.feature @@ -31,57 +31,8 @@ Feature: Project Settings Given there is 1 project with the following: | name | project1 | | identifier | project1 | - And there is 1 user with the following: - | login | bob | - | firstname | Bob | - | Lastname | Bobbit | - And there is 1 user with the following: - | login | alice | - | firstname | Alice | - | Lastname | Alison | - And there is a role "alpha" - And there is a role "beta" - And the user "bob" is a "alpha" in the project "project1" - And the user "alice" is a "beta" in the project "project1" Given I am already admin - @javascript - Scenario: Adding a Role to a Member - When I go to the members tab of the settings page of the project "project1" - When I click on "Edit" within "#member-1" - And I check "beta" within "#member-1-roles-form" - And I click "Change" within "#member-1-roles-form" - Then I should see "alpha" within "#member-1-roles" - And I should see "beta" within "#member-1-roles" - - @javascript - Scenario: Removing one Role from while adding another Role to a Member - When I go to the members tab of the settings page of the project "project1" - When I click on "Edit" within "#member-1" - And I uncheck "alpha" within "#member-1-roles-form" - And I check "beta" within "#member-1-roles-form" - And I click "Change" within "#member-1-roles-form" - Then I should see "beta" within "#member-1-roles" - And I should not see "alpha" within "#member-1-roles" - - @javascript - Scenario: Removing the last Role of a Member - When I go to the members tab of the settings page of the project "project1" - When I click on "Edit" within "#member-1" - And I uncheck "alpha" within "#member-1-roles-form" - And I click "Change" within "#member-1-roles-form" - Then there should be an error message - And I should see "Bob Bobbit" within ".generic-table" - And I should see "alpha" within ".generic-table" - - - Scenario: Changing members per page keeps us on the members tab - When I go to the settings page of the project "project1" - And I follow "Members" within ".tabs" - And I follow "20" within ".pagination--options" within "#tab-content-members" - Then I should be on the members tab of the settings page of the project "project1" - - Scenario: Adding a Work Package custom field to the project When the following issue custom fields are defined: | name | type | is_for_all | diff --git a/features/users/add_user.feature b/features/users/add_user.feature index 95e040251d..1886e68125 100644 --- a/features/users/add_user.feature +++ b/features/users/add_user.feature @@ -31,15 +31,12 @@ Feature: Adding a user Scenario: as an admin a user can be created Given I am already admin When I go to the new user page - And I fill in "psmith" for "user_login" And I fill in "Paul" for "user_firstname" And I fill in "Smith" for "user_lastname" And I fill in "psmith@somenet.foo" for "user_mail" - And I fill in "psmithPSMITH09" for "user_password" - And I fill in "psmithPSMITH09" for "user_password_confirmation" And I submit the form by the "Create" button Then I should see "Successful creation" - And I should be on the edit page of the user "psmith" + And I should be on the edit page of the user "psmith@somenet.foo" When I logout - And I login as "psmith" with password "psmithPSMITH09" - Then I should be logged in as "psmith" + And I login as "psmith@somenet.foo" with password "psmithPSMITH09" + Then I should see "Your account has not yet been activated." diff --git a/features/users/random_password_assignment.feature b/features/users/random_password_assignment.feature index fb3cd1c9f6..08be20f05a 100644 --- a/features/users/random_password_assignment.feature +++ b/features/users/random_password_assignment.feature @@ -40,18 +40,6 @@ Feature: User Status Then there should be a flash error message And there should be a "New password" field - @javascript - Scenario: New user can be assigned a random password - When I create a new user - And I check the assign random password to user field - And I save the new user - Then an e-mail should be sent containing "Password" - When I try to log in with user "newbobby" - Then I should not see "Bob Bobbit" - When I try to log in with user "newbobby" and the password sent via email - Then there should be a flash error message - And there should be a "New password" field - @javascript Scenario: Password fields are disabled and cleared when random password assignment is activated When I edit the user "bobby" diff --git a/lib/open_project/notifications.rb b/lib/open_project/notifications.rb index 5ba3981656..7f4600451b 100644 --- a/lib/open_project/notifications.rb +++ b/lib/open_project/notifications.rb @@ -31,13 +31,29 @@ module OpenProject # Subscribe to a specific event with name # Contrary to ActiveSupport::Notifications, we don't support regexps here, but only # single events specified as string. - def self.subscribe(name, &block) + # + # @param name [String] The name of the event to subscribe to. + # @param clear_subscriptions [Boolean] Clears all previous subscriptions to this + # event if true. Use with care! + # @return nil + # @raises ArgumentError if no block is given. + def self.subscribe(name, clear_subscriptions: false, &block) # if no block is given, raise an error raise ArgumentError, 'please provide a block as a callback' unless block_given? - ActiveSupport::Notifications.subscribe(name.to_s) do |_name, _start, _finish, _id, payload| + if clear_subscriptions + subscriptions[name].each do |sub| + ActiveSupport::Notifications.unsubscribe sub + end + end + + sub = ActiveSupport::Notifications.subscribe(name.to_s) do |_, _, _, _, payload| block.call(payload) end + + subs = clear_subscriptions ? [] : Array(subscriptions[name]) + subscriptions[name] = subs + [sub] + # Don't return a subscription object as it's an implementation detail. nil end @@ -49,5 +65,11 @@ module OpenProject def self.send(name, payload) ActiveSupport::Notifications.instrument(name, payload) end + + def self.subscriptions + @subscriptions ||= {} + end + + private_class_method :subscriptions end end diff --git a/lib/open_project/object_linking.rb b/lib/open_project/object_linking.rb index 7265fa33b8..560f0d7444 100644 --- a/lib/open_project/object_linking.rb +++ b/lib/open_project/object_linking.rb @@ -33,7 +33,7 @@ module OpenProject def link_to_user(user, options = {}) if user.is_a?(User) name = user.name(options.delete(:format)) - if user.active? || user.registered? + if user.active? || user.registered? || user.invited? link_to(name, user, options) else name diff --git a/lib/tasks/ldap.rake b/lib/tasks/ldap.rake new file mode 100644 index 0000000000..56d59bcdf4 --- /dev/null +++ b/lib/tasks/ldap.rake @@ -0,0 +1,60 @@ +#-- encoding: UTF-8 +#-- 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. +#++ + +desc 'Creates a dummy LDAP auth source for logging in any user using the password "dummy".' +namespace :ldap do + task create_dummy: :environment do + source_name = 'DerpLAP' + otf_reg = ARGV.include?('onthefly_register') + + source = DummyAuthSource.create name: source_name, onthefly_register: otf_reg + + puts + if source.valid? + puts "Created dummy auth source called \"#{source_name}\"" + puts 'On-the-fly registration support: ' + otf_reg.to_s + unless otf_reg + puts "use `rake ldap:create_dummy[onthefly_register]` to enable on-the-fly registration" + end + else + puts "Dummy auth source already exists. It's called \"#{source_name}\"." + end + + puts + puts 'Note: Dummy auth sources cannot be edited, so clicking on them' + puts " in the 'LDAP Authentication' view will result in an error. Bummer!" + end + + task delete_dummies: :environment do + DummyAuthSource.destroy_all + + puts + puts 'Deleted all dummy auth sources. Users who used it are out of luck! :o' + end +end diff --git a/spec/controllers/members_controller_spec.rb b/spec/controllers/members_controller_spec.rb index 88daa73d1b..f69c543895 100644 --- a/spec/controllers/members_controller_spec.rb +++ b/spec/controllers/members_controller_spec.rb @@ -31,7 +31,7 @@ require 'spec_helper' describe MembersController, type: :controller do let(:admin) { FactoryGirl.create(:admin) } let(:user) { FactoryGirl.create(:user) } - let(:project) { FactoryGirl.create(:project) } + let(:project) { FactoryGirl.create(:project, identifier: 'pet_project') } let(:role) { FactoryGirl.create(:role) } let(:member) { FactoryGirl.create(:member, project: project, @@ -136,36 +136,30 @@ describe MembersController, type: :controller do let(:user2) { FactoryGirl.create(:user) } let(:user3) { FactoryGirl.create(:user) } let(:user4) { FactoryGirl.create(:user) } - let(:valid_params) { - { format: 'js', - project_id: project.id, - member: { role_ids: [role.id], - user_ids: [user2.id, user3.id, user4.id] } } - } - let(:invalid_params) { - { format: 'js', - project_id: project.id, - member: { role_ids: [], - user_ids: [user2.id, user3.id, user4.id] } } - } context 'post :create' do context 'single member' do - let(:action) { post :create, project_id: project.id, member: { role_ids: [role.id], user_id: user2.id } } + let(:action) do + post :create, project_id: project.id, member: { role_ids: [role.id], user_id: user2.id } + end it 'should add a member' do expect { action }.to change { Member.count }.by(1) - expect(response).to redirect_to(settings_project_path(project) + '/members') + expect(response).to redirect_to '/projects/pet_project/members' expect(user2).to be_member_of(project) end end context 'multiple members' do - let(:action) { post :create, project_id: project.id, member: { role_ids: [role.id], user_ids: [user2.id, user3.id, user4.id] } } + let(:action) do + post :create, + project_id: project.id, + member: { role_ids: [role.id], user_ids: [user2.id, user3.id, user4.id] } + end it 'should add all members' do expect { action }.to change { Member.count }.by(3) - expect(response).to redirect_to(settings_project_path(project) + '/members') + expect(response).to redirect_to '/projects/pet_project/members' expect(user2).to be_member_of(project) expect(user3).to be_member_of(project) expect(user4).to be_member_of(project) @@ -173,36 +167,23 @@ describe MembersController, type: :controller do end end - context 'post :create in JS format' do - context 'with successful saves' do - before do - post :create, valid_params - end - - it 'should add members' do - expect(user2).to be_member_of(project) - expect(user3).to be_member_of(project) - expect(user4).to be_member_of(project) - end + context 'with a failed save' do + let(:invalid_params) { + { project_id: project.id, + member: { role_ids: [], + user_ids: [user2.id, user3.id, user4.id] } } + } - it 'should replace the tab with RJS' do - assert_select_rjs :replace_html, 'tab-content-members' - end + before do + post :create, invalid_params end - end - context 'with a failed save' do - it 'should not replace the tab with RJS' do - post :create, invalid_params - assert_select '#tab-content-members', 0 + it 'should not redirect to the members index' do + expect(response).not_to redirect_to '/projects/pet_project/members' end it 'should show an error message' do - post :create, invalid_params - - assert_select_rjs :insert_html, :top do - assert_select '#errorExplanation' - end + expect(response.body).to include 'choose at least one role' end end end @@ -215,7 +196,7 @@ describe MembersController, type: :controller do it 'should destroy a member' do expect { action }.to change { Member.count }.by(-1) - expect(response).to redirect_to(settings_project_path(project) + '/members') + expect(response).to redirect_to '/projects/pet_project/members' expect(user).not_to be_member_of(project) end end @@ -230,7 +211,7 @@ describe MembersController, type: :controller do it 'should update the member' do expect { action }.not_to change { Member.count } - expect(response).to redirect_to(settings_project_path(project) + '/members') + expect(response).to redirect_to '/projects/pet_project/members' end end end diff --git a/spec/factories/auth_source_factory.rb b/spec/factories/auth_source_factory.rb index bafc897e71..0c178848db 100644 --- a/spec/factories/auth_source_factory.rb +++ b/spec/factories/auth_source_factory.rb @@ -36,4 +36,8 @@ FactoryGirl.define do port 225 # a reserved port, should not be in use attr_login 'uid' end + + factory :dummy_auth_source, class: DummyAuthSource do + name 'DerpLAP' + end end diff --git a/spec/features/groups/group_memberships_spec.rb b/spec/features/groups/group_memberships_spec.rb new file mode 100644 index 0000000000..4a2fd47e19 --- /dev/null +++ b/spec/features/groups/group_memberships_spec.rb @@ -0,0 +1,96 @@ +#-- 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 'group memberships through groups page', type: :feature do + let!(:project) { FactoryGirl.create :project, name: 'Project 1', identifier: 'project1' } + + let(:admin) { FactoryGirl.create :admin } + let!(:peter) { FactoryGirl.create :user, firstname: 'Peter', lastname: 'Pan' } + let!(:hannibal) { FactoryGirl.create :user, firstname: 'Hannibal', lastname: 'Smith' } + let(:group) { FactoryGirl.create :group, lastname: 'A-Team' } + + let!(:manager) { FactoryGirl.create :role, name: 'Manager' } + let!(:developer) { FactoryGirl.create :role, name: 'Developer' } + + let(:members_page) { Pages::Members.new project.identifier } + let(:group_page) { Pages::Groups.new.group(group.id) } + + before do + allow(User).to receive(:current).and_return admin + + group.add_member! peter + end + + scenario 'adding a user to a group adds the user to the project as well', js: true do + members_page.visit! + expect(members_page).not_to have_user 'Hannibal Smith' + + group_page.visit! + group_page.add_to_project! 'Project 1', as: 'Manager' + expect(page).to have_text 'Successful update' + + group_page.add_user! 'Hannibal' + + members_page.visit! + expect(members_page).to have_user 'A-Team', roles: ['Manager'] + expect(members_page).to have_user 'Peter Pan', roles: ['Manager'] + expect(members_page).to have_user 'Hannibal Smith', roles: ['Manager'] + end + + context 'given a group with members in a project' do + before do + group.add_member! hannibal + project.add_member! group, [manager] + end + + scenario 'removing a user from the group removes them from the project too' do + members_page.visit! + expect(members_page).to have_user 'Hannibal Smith' + + group_page.visit! + group_page.remove_user! 'Hannibal Smith' + + members_page.visit! + expect(members_page).to have_user 'A-Team' + expect(members_page).to have_user 'Peter Pan' + expect(members_page).not_to have_user 'Hannibal Smith' + end + + scenario 'removing the group from a project', js: true do + group_page.visit! + group_page.open_projects_tab! + expect(group_page).to have_project 'Project 1' + + group_page.remove_from_project! 'Project 1' + expect(page).to have_text 'Successful deletion' + expect(page).to have_text 'No data to display' + end + end +end diff --git a/features/groups/group_delete.feature b/spec/features/groups/groups_spec.rb similarity index 67% rename from features/groups/group_delete.feature rename to spec/features/groups/groups_spec.rb index 34ae39e509..c4d4ce645c 100644 --- a/features/groups/group_delete.feature +++ b/spec/features/groups/groups_spec.rb @@ -26,15 +26,25 @@ # See doc/COPYRIGHT.rdoc for more details. #++ -Feature: Simple deletion of a group +require 'spec_helper' -Background: -Given We have the group "Bob's Team" +feature 'group memberships through groups page', type: :feature do + let(:admin) { FactoryGirl.create :admin } + let!(:group) { FactoryGirl.create :group, lastname: "Bob's Team" } -@javascript -Scenario: An admin can delete an existing group -Given I am admin -Given I am on the groups administration page -And I click on "Delete" -And I accept the alert dialog -Then I should not see "Bob's Team" within "#content" + let(:groups_page) { Pages::Groups.new } + + context 'as an admin' do + before do + allow(User).to receive(:current).and_return admin + end + + scenario 'I can delete a group' do + groups_page.visit! + expect(groups_page).to have_group "Bob's Team" + + groups_page.delete_group! "Bob's Team" + expect(groups_page).not_to have_group "Bob's Team" + end + end +end diff --git a/spec/features/groups/membership_spec.rb b/spec/features/groups/membership_spec.rb new file mode 100644 index 0000000000..24bb6e80cc --- /dev/null +++ b/spec/features/groups/membership_spec.rb @@ -0,0 +1,126 @@ +#-- 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 'group memberships through project members page', type: :feature do + let(:project) { FactoryGirl.create :project, name: 'Project 1', identifier: 'project1' } + + let(:admin) { FactoryGirl.create :admin } + let(:alice) { FactoryGirl.create :user, firstname: 'Alice', lastname: 'Wonderland' } + let(:bob) { FactoryGirl.create :user, firstname: 'Bob', lastname: 'Bobbit' } + let(:group) { FactoryGirl.create :group, lastname: 'group1' } + + let!(:alpha) { FactoryGirl.create :role, name: 'alpha', permissions: [:manage_members] } + let!(:beta) { FactoryGirl.create :role, name: 'beta' } + + let(:members_page) { Pages::Members.new project.identifier } + let(:groups_page) { Pages::Groups.new } + + before do + FactoryGirl.create :member, user: bob, project: project, roles: [alpha] + end + + context 'given a group with members' do + before do + allow(User).to receive(:current).and_return bob + + group.add_member! alice + end + + scenario 'adding group1 as a member with the beta role', js: true do + members_page.visit! + members_page.add_user! 'group1', as: 'beta' + + expect(members_page).to have_added_user 'group1' + expect(members_page).to have_user('Alice Wonderland', group_membership: true) + end + + context 'which has has been added to a project' do + before do + project.add_member! group, [beta] + end + + context 'with the members having no roles of their own' do + scenario 'removing the group removes its members too' do + members_page.visit! + expect(members_page).to have_user('Alice Wonderland') + + members_page.remove_user! 'group1' + expect(page).to have_text('Removed group1 from project') + + expect(members_page).not_to have_user('group1') + expect(members_page).not_to have_user('Alice Wonderland') + end + end + + context 'with the members having roles of their own' do + before do + project.members + .select { |m| m.user_id == alice.id } + .each { |m| m.add_and_save_role alpha } + end + + scenario 'removing the group leaves the user without their group roles' do + members_page.visit! + expect(members_page).to have_user('Alice Wonderland', roles: ['alpha', 'beta']) + + members_page.remove_user! 'group1' + expect(page).to have_text('Removed group1 from project') + + expect(members_page).not_to have_user('group1') + + expect(members_page).to have_user('Alice Wonderland', roles: ['alpha']) + expect(members_page).not_to have_roles('Alice Wonderland', ['beta']) + end + end + end + end + + context 'given an empty group in a project' do + before do + alice # create alice + project.add_member! group, [beta] + + allow(User).to receive(:current).and_return admin + end + + scenario 'adding members to that group adds them to the project too' do + members_page.visit! + + expect(members_page).not_to have_user('Alice Wonderland') # Alice not in the project yet + expect(members_page).to have_user('group1') # the group is already there though + + groups_page.visit! + groups_page.add_user_to_group! 'Alice Wonderland', 'group1' + + members_page.visit! + expect(members_page).to have_user('Alice Wonderland', roles: ['beta']) + end + end +end diff --git a/spec/features/members/error_messages_spec.rb b/spec/features/members/error_messages_spec.rb new file mode 100644 index 0000000000..5218afbaab --- /dev/null +++ b/spec/features/members/error_messages_spec.rb @@ -0,0 +1,73 @@ +#-- 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 'group memberships through groups page', type: :feature do + let!(:project) { FactoryGirl.create :project, name: 'Project 1', identifier: 'project1' } + + let(:admin) { FactoryGirl.create :admin } + let!(:peter) { FactoryGirl.create :user, firstname: 'Peter', lastname: 'Pan' } + + let!(:manager) { FactoryGirl.create :role, name: 'Manager' } + + let(:members_page) { Pages::Members.new project.identifier } + + before do + allow(User).to receive(:current).and_return admin + end + + shared_examples 'errors when adding members' do + scenario 'adding a principal without a role, non impaired', js: true do + members_page.visit! + members_page.add_user! 'Peter Pan', as: nil + + expect(page).to have_text 'choose at least one role' + end + + scenario 'adding a role without a principal, non impaired', js: true do + members_page.visit! + members_page.add_user! nil, as: 'Manager' + + expect(page).to have_text 'choose at least one user or group' + end + end + + context 'with an impaired user' do + before do + admin.impaired = true + admin.save! + end + + it_behaves_like 'errors when adding members' + end + + context 'with an un-impaired user' do + it_behaves_like 'errors when adding members' + end +end diff --git a/spec/features/members/membership_spec.rb b/spec/features/members/membership_spec.rb new file mode 100644 index 0000000000..18a479fd9e --- /dev/null +++ b/spec/features/members/membership_spec.rb @@ -0,0 +1,127 @@ +#-- 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 'group memberships through groups page', type: :feature, js: true do + let!(:project) { FactoryGirl.create :project, name: 'Project 1', identifier: 'project1' } + + let(:admin) { FactoryGirl.create :admin } + let!(:peter) { FactoryGirl.create :user, firstname: 'Peter', lastname: 'Pan' } + let!(:hannibal) { FactoryGirl.create :user, firstname: 'Hannibal', lastname: 'Smith' } + let!(:crash) { FactoryGirl.create :user, firstname: "", + lastname: "" } + + let(:group) { FactoryGirl.create :group, lastname: 'A-Team' } + + let!(:manager) { FactoryGirl.create :role, name: 'Manager' } + let!(:developer) { FactoryGirl.create :role, name: 'Developer' } + + let(:members_page) { Pages::Members.new project.identifier } + + before do + allow(User).to receive(:current).and_return admin + + group.add_member! peter + group.add_member! hannibal + end + + shared_examples 'adding and removing principals' do + scenario 'Adding and Removing a Group as Member' do + members_page.visit! + members_page.add_user! 'A-Team', as: 'Manager' + + expect(members_page).to have_added_user 'A-Team' + + members_page.remove_user! 'A-Team' + expect(page).to have_text 'Removed A-Team from project' + expect(page).to have_text 'Nothing to display' + end + + scenario 'Adding and removing a User as Member' do + members_page.visit! + members_page.add_user! 'Hannibal Smith', as: 'Manager' + + expect(members_page).to have_added_user 'Hannibal Smith' + + members_page.remove_user! 'Hannibal Smith' + expect(page).to have_text 'Removed Hannibal Smith from project' + expect(page).to have_text 'Nothing to display' + end + + scenario 'Entering a Username as Member in firstname, lastname order' do + members_page.visit! + members_page.open_new_member! + + members_page.enter_principal_search! 'Hannibal S' + expect(page).to have_text 'Hannibal Smith' + end + + scenario 'Entering a Username as Member in lastname, firstname order' do + members_page.visit! + members_page.open_new_member! + + members_page.enter_principal_search! 'Smith, H' + expect(page).to have_text 'Hannibal Smith' + end + + scenario 'Escaping should work properly when entering a name' do + members_page.visit! + members_page.open_new_member! + members_page.enter_principal_search! 'script' + + expect(members_page).not_to have_alert_dialog + expect(page).to have_text "" + end + end + + context 'with an impaired user' do + before do + admin.impaired = true + admin.save! + end + + it_behaves_like 'adding and removing principals' + end + + context 'with an un-impaired user' do + it_behaves_like 'adding and removing principals' + + # The following scenario is only tested with an unimpaired user + # as it does not make a difference whether or not the user is impaired. + + scenario 'Escaping should work properly when selecting a user' do + members_page.visit! + members_page.open_new_member! + members_page.select_principal! 'script' + + expect(members_page).not_to have_alert_dialog + expect(page).to have_text "" + end + end +end diff --git a/spec/features/members/pagination_spec.rb b/spec/features/members/pagination_spec.rb new file mode 100644 index 0000000000..bb6ce12c36 --- /dev/null +++ b/spec/features/members/pagination_spec.rb @@ -0,0 +1,84 @@ +#-- 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 'members pagination', type: :feature, js: true do + let!(:project) { FactoryGirl.create :project, name: 'Project 1', identifier: 'project1' } + + let(:admin) { FactoryGirl.create :admin } + let!(:peter) { FactoryGirl.create :user, firstname: 'Peter', lastname: 'Pan' } + let!(:bob) { FactoryGirl.create :user, firstname: 'Bob', lastname: 'Bobbit' } + let!(:alice) { FactoryGirl.create :user, firstname: 'Alice', lastname: 'Alison' } + + let!(:manager) { FactoryGirl.create :role, name: 'Manager' } + let!(:developer) { FactoryGirl.create :role, name: 'Developer' } + + let(:members_page) { Pages::Members.new project.identifier } + + before do + allow(User).to receive(:current).and_return admin + + project.add_member! bob, [manager] + project.add_member! alice, [developer] + end + + scenario 'paginating after adding a member' do + members_page.set_items_per_page! 2 + + members_page.visit! + members_page.add_user! 'Peter Pan', as: 'Manager' + + members_page.go_to_page! 2 + expect(members_page).to have_user 'Peter Pan' + end + + scenario 'Paginating after removing a member' do + project.add_member! peter, [manager] + members_page.set_items_per_page! 1 + + members_page.visit! + members_page.remove_user! 'Alice Alison' + expect(members_page).to have_user 'Bob Bobbit' + + members_page.go_to_page! 2 + expect(members_page).to have_user 'Peter Pan' + end + + scenario 'Paginating after updating a member' do + members_page.set_items_per_page! 1 + + members_page.visit! + members_page.edit_user! 'Alice Alison', add_roles: ['Manager'] + expect(page).to have_text 'Successful update' + expect(members_page).to have_user 'Alice Alison', roles: ['Developer', 'Manager'] + + members_page.go_to_page! 2 + expect(members_page).to have_user 'Bob Bobbit' + end +end diff --git a/spec/features/members/roles_spec.rb b/spec/features/members/roles_spec.rb new file mode 100644 index 0000000000..d8b7f87b05 --- /dev/null +++ b/spec/features/members/roles_spec.rb @@ -0,0 +1,71 @@ +#-- 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 'members pagination', type: :feature, js: true do + let!(:project) { FactoryGirl.create :project, name: 'Project 1', identifier: 'project1' } + + let(:admin) { FactoryGirl.create :admin } + let!(:bob) { FactoryGirl.create :user, firstname: 'Bob', lastname: 'Bobbit' } + let!(:alice) { FactoryGirl.create :user, firstname: 'Alice', lastname: 'Alison' } + + let!(:alpha) { FactoryGirl.create :role, name: 'alpha' } + let!(:beta) { FactoryGirl.create :role, name: 'beta' } + + let(:members_page) { Pages::Members.new project.identifier } + + before do + allow(User).to receive(:current).and_return admin + + project.add_member! alice, [beta] + project.add_member! bob, [alpha] + + members_page.visit! + end + + scenario 'Adding a Role to Alice' do + members_page.edit_user! 'Alice Alison', add_roles: ['alpha'] + + expect(members_page).to have_user('Alice Alison', roles: ['alpha', 'beta']) + end + + scenario 'Adding a role while taking another role away from Alice' do + members_page.edit_user! 'Alice Alison', add_roles: ['alpha'], remove_roles: ['beta'] + + expect(members_page).to have_user('Alice Alison', roles: 'alpha') + expect(members_page).not_to have_roles('Alice Alison', ['beta']) + end + + scenario "Removing Bob's last role results in an error" do + members_page.edit_user! 'Bob Bobbit', remove_roles: ['alpha'] + + expect(page).to have_text 'choose at least one role' + expect(members_page).to have_user('Bob Bobbit', roles: ['alpha']) + end +end diff --git a/spec/features/users/create_spec.rb b/spec/features/users/create_spec.rb index a220f3ade8..406150591f 100644 --- a/spec/features/users/create_spec.rb +++ b/spec/features/users/create_spec.rb @@ -31,13 +31,17 @@ require 'spec_helper' describe 'create users', type: :feature do let(:current_user) { FactoryGirl.create :admin } - let(:auth_source) { FactoryGirl.build :auth_source } + let(:auth_source) { FactoryGirl.build :dummy_auth_source } before do allow(User).to receive(:current).and_return current_user end shared_examples_for 'successful user creation' do + let(:mail) { ActionMailer::Base.deliveries.last } + let(:mail_body) { mail.body.parts.first.to_s } + let(:token) { mail_body.scan(/token=(.*)$/).first.first } + it 'creates the user' do expect(page).to have_selector('.flash', 'Successfully created.') @@ -45,23 +49,48 @@ describe 'create users', type: :feature do expect(current_path).to eql(edit_user_path(new_user.id)) end + + it 'sends out an activation email' do + expect(mail_body).to include 'activate your account' + expect(token).not_to be_nil + end end context 'with internal authentication' do before do visit new_user_path - fill_in 'Login', with: 'bob' fill_in 'First name', with: 'bobfirst' fill_in 'Last name', with: 'boblast' fill_in 'Email', with: 'bob@mail.com' - fill_in 'Password', with: 'BobBobBob123' - fill_in 'Confirmation', with: 'BobBobBob123' click_button 'Create' end - it_behaves_like 'successful user creation' + it_behaves_like 'successful user creation' do + describe 'activation' do + before do + allow(User).to receive(:current).and_call_original + + visit "/account/activate?token=#{token}" + end + + it 'shows the registration form' do + expect(page).to have_text 'Create a new account' + end + + it 'registers the user upon submission' do + fill_in 'user_password', with: 'foobarbaz1' + fill_in 'user_password_confirmation', with: 'foobarbaz1' + + click_button 'Submit' + + # landed on the 'my page' + expect(page).to have_text 'your account has been activated' + expect(page).to have_text 'Login: bob@mail.com' + end + end + end end context 'with external authentication', js: true do @@ -70,15 +99,39 @@ describe 'create users', type: :feature do visit new_user_path - fill_in 'Login', with: 'bob' fill_in 'First name', with: 'bobfirst' fill_in 'Last name', with: 'boblast' fill_in 'Email', with: 'bob@mail.com' + select auth_source.name, from: 'Authentication mode' + fill_in 'Login', with: 'bob' click_button 'Create' end - it_behaves_like 'successful user creation' + it_behaves_like 'successful user creation' do + describe 'activation' do + before do + allow(User).to receive(:current).and_call_original + + visit "/account/activate?token=#{token}" + end + + it 'shows the login form prompting the user to login' do + expect(page).to have_text 'Please login as bob to activate your account.' + end + + it 'registers the user upon submission' do + # login is already filled with 'bob' + fill_in 'password', with: 'dummy' # accepted by DummyAuthSource + + click_button 'Sign in' + + # landed on the 'my page' + expect(page).to have_text 'My account' + expect(page).to have_text 'Login: bob' + end + end + end end end diff --git a/spec/legacy/functional/users_controller_spec.rb b/spec/legacy/functional/users_controller_spec.rb index 4e9f9fe899..3819fe34a7 100644 --- a/spec/legacy/functional/users_controller_spec.rb +++ b/spec/legacy/functional/users_controller_spec.rb @@ -162,8 +162,7 @@ describe UsersController, type: :controller do mail: 'jdoe@gmail.com', mail_notification: 'none' }, - pref: { }, - send_information: '1' + pref: { } end end @@ -175,12 +174,17 @@ describe UsersController, type: :controller do assert_equal 'jdoe', user.login assert_equal 'jdoe@gmail.com', user.mail assert_equal 'none', user.mail_notification - assert user.check_password?('adminADMIN!') + assert user.passwords.empty? # no password is assigned during creation mail = ActionMailer::Base.deliveries.last refute_nil mail assert_equal [user.mail], mail.to - assert mail.body.encoded.include?('adminADMIN!') + + activation_link = Regexp.new( + "http://#{Setting.host_name}/account/activate\\?token=[a-f0-9]+", + Regexp::MULTILINE) + + assert(mail.body.encoded =~ activation_link) end it 'should create with failure' do diff --git a/spec/lib/open_project/notifications_spec.rb b/spec/lib/open_project/notifications_spec.rb index 0a8a3cbe60..498f65852a 100644 --- a/spec/lib/open_project/notifications_spec.rb +++ b/spec/lib/open_project/notifications_spec.rb @@ -55,5 +55,45 @@ describe OpenProject::Notifications do OpenProject::Notifications.subscribe('notifications_spec_send') }.to raise_error ArgumentError, /provide a block as a callback/ end + + describe 'clear_subscriptions:' do + let(:key) { 'test_clear_subs' } + let(:as) { [] } + let(:bs) { [] } + + def example_with(clear:) + OpenProject::Notifications.subscribe(key) do |out| + as << out + end + OpenProject::Notifications.send(key, 1) + + OpenProject::Notifications.subscribe(key, clear_subscriptions: clear) do |out| + bs << out + end + OpenProject::Notifications.send(key, 2) + end + + context 'true' do + before do + example_with clear: true + end + + it 'clears previous subscriptions' do + expect(as).to eq [1] + expect(bs).to eq [2] + end + end + + context 'false' do + before do + example_with clear: false + end + + it 'notifies both subscriptions' do + expect(as).to eq [1, 2] + expect(bs).to eq [2] + end + end + end end end diff --git a/spec/support/pages/groups.rb b/spec/support/pages/groups.rb new file mode 100644 index 0000000000..c5c77b6145 --- /dev/null +++ b/spec/support/pages/groups.rb @@ -0,0 +1,128 @@ +#-- 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 'support/pages/page' + +module Pages + class Groups < Page + def path + '/admin/groups' + end + + def edit_group!(group_name) + click_on group_name + end + + def add_user_to_group!(user_name, group_name) + visit_page unless current_page? + + edit_group! group_name + group(group_name).add_user! user_name + end + + def delete_group!(name) + find_group(name).find('a[data-method=delete]').click + accept_alert_dialog! + end + + def find_group(name) + find('tr', text: name) + end + + def has_group?(name) + has_selector? 'tr', text: name + end + + def group(group_name) + Group.new group_name + end + end + + class Group < Pages::Page + attr_reader :id + + def initialize(id) + @id = id + end + + def path + "/admin/groups/#{id}/edit" + end + + def open_users_tab! + click_on 'tab-users' + end + + def open_projects_tab! + click_on 'tab-memberships' + end + + def add_to_project!(project_name, as:) + open_projects_tab! + select_project! project_name + Array(as).each { |role| check role } + click_on 'Add' + end + + def remove_from_project!(name) + open_projects_tab! + find_project(name).find('a[data-method=delete]').click + end + + def find_project(name) + find('tr', text: name) + end + + def has_project?(name) + has_selector? 'tr', text: name + end + + def select_project!(project_name) + select(project_name, from: 'membership_project_id') + end + + def add_user!(user_name) + open_users_tab! + check user_name + click_on 'Add' + end + + def remove_user!(user_name) + open_users_tab! + find_user(user_name).find('a[data-method=delete]').click + end + + def find_user(user_name) + find('tr', text: user_name) + end + + def has_user?(user_name) + has_selector? 'tr', text: user_name + end + end +end diff --git a/spec/support/pages/members.rb b/spec/support/pages/members.rb new file mode 100644 index 0000000000..9342c27aa9 --- /dev/null +++ b/spec/support/pages/members.rb @@ -0,0 +1,143 @@ +#-- 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 'support/pages/page' + +module Pages + class Members < Page + include Capybara::Select2 + + attr_reader :project_identifier + + def initialize(project_identifier) + @project_identifier = project_identifier + end + + def path + "/projects/#{project_identifier}/members" + end + + def open_new_member! + click_on 'Add Member' + end + + ## + # Adds the given user to this project. + # + # @param user_name [String] The full name of the user. + # @param as [String] The role as which the user should be added. + def add_user!(user_name, as:) + click_on 'Add Member' + + select_principal! user_name if user_name + select_role! as if as + + click_on 'Add' + end + + def remove_user!(user_name) + find_user(user_name).find('a[data-method=delete]').click + end + + def has_added_user?(name) + has_text? "Added #{name} to the project" and + has_css? 'tr', text: name + end + + ## + # Checks if the members page lists the given user. + # + # @param name [String] The full name of the user. + # @param roles [Array] Checks if the user has the given role. + # @param group_membership [Boolean] True if the member is added through a group. + # Such members cannot be removed separately which + # is why there must be only an edit and no delete button. + def has_user?(name, roles: nil, group_membership: nil) + has_selector?('tr', text: name) && + (roles.nil? || has_roles?(name, roles)) && + (group_membership.nil? || group_membership == has_group_membership?(name)) + end + + def find_user(name) + find('tr', text: name) + end + + def edit_user!(name, add_roles: [], remove_roles: []) + user = find_user(name) + user.find('a[title=Edit]').click + + Array(add_roles).each { |role| check role } + Array(remove_roles).each { |role| uncheck role } + + click_on 'Change' + end + + def has_group_membership?(user_name) + user = find_user(user_name) + + user.has_selector?('a[title=Edit]') && + user.has_no_selector?('a[title=Delete]') + end + + def has_roles?(user_name, roles) + user = find_user(user_name) + + Array(roles).all? { |role| user.has_text? role } + end + + def select_principal!(principal_name) + if !User.current.impaired? + select2(principal_name, css: '#s2id_member_user_ids') + else + find('form .principals').check principal_name + end + end + + def select_role!(role_name) + if !User.current.impaired? + select2(role_name, css: '#s2id_member_role_ids') + else + find('form .roles').check role_name + end + end + + def enter_principal_search!(principal_name) + if !User.current.impaired? + find('#s2id_member_user_ids') + .find('.select2-choices .select2-input') + .set(principal_name) + else + fill_in 'principal_search', with: principal_name + end + end + + def go_to_page!(number) + find('.pagination a', text: number.to_s).click + end + end +end diff --git a/spec/support/pages/page.rb b/spec/support/pages/page.rb new file mode 100644 index 0000000000..d3aeeac4aa --- /dev/null +++ b/spec/support/pages/page.rb @@ -0,0 +1,80 @@ +#-- 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. +#++ + +module Pages + class Page + include Capybara::DSL + include RSpec::Matchers + + def current_page? + URI.parse(current_url).path == path + end + + def visit! + raise 'No path defined' unless path + + visit path + + self + end + + def accept_alert_dialog! + alert_dialog.accept if selenium_driver? + end + + def dismiss_alert_dialog! + alert_dialog.dismiss if selenium_driver? + end + + def alert_dialog + page.driver.browser.switch_to.alert + end + + def has_alert_dialog? + if selenium_driver? + begin + page.driver.browser.switch_to.alert + rescue Selenium::WebDriver::Error::NoAlertPresentError + false + end + end + end + + def selenium_driver? + Capybara.current_driver.to_s.include?('selenium') + end + + def set_items_per_page!(n) + Setting.per_page_options = "#{n}, 50, 100" + end + + def path + nil + end + end +end