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 != '' %> -
+
<% unless no_pwd %> <% end %>