Merge pull request #6360 from opf/feature/user_limits

Feature: user limits
pull/6372/head
ulferts 6 years ago committed by GitHub
commit c1e4268a50
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 10
      app/assets/javascripts/members_form.js
  2. 5
      app/assets/stylesheets/openproject/_generic.sass
  3. 2
      app/assets/stylesheets/openproject/_index.sass
  4. 11
      app/controllers/account_controller.rb
  5. 84
      app/controllers/concerns/user_limits.rb
  6. 6
      app/controllers/members_controller.rb
  7. 18
      app/controllers/users_controller.rb
  8. 14
      app/helpers/toolbar_helper.rb
  9. 8
      app/views/enterprises/_current.html.erb
  10. 7
      app/views/members/_member_form_non_impaired.html.erb
  11. 11
      app/views/users/index.html.erb
  12. 6
      config/locales/en.yml
  13. 27
      docs/configuration/configuration.md
  14. 67
      lib/open_project/enterprise.rb
  15. 60
      spec/controllers/account_controller_spec.rb
  16. 58
      spec/controllers/users_controller_spec.rb
  17. 80
      spec/lib/open_project/enterprise_spec.rb
  18. 73
      spec/views/users/index.html.erb_spec.rb

@ -76,6 +76,16 @@ function showAddMemberForm() {
jQuery('#filter-member-button').removeClass('-active');
localStorage.setItem("showFilter", 'false');
jQuery('#add-member-button').prop('disabled', true);
jQuery("input#member_user_ids").on("change", function() {
var values = jQuery("input#member_user_ids").val();
if (values.indexOf("@") != -1) {
jQuery("#member-user-limit-warning").css("display", "block");
} else {
jQuery("#member-user-limit-warning").css("display", "none");
}
});
}
function hideAddMemberForm() {

@ -0,0 +1,5 @@
.no-padding-bottom
padding-bottom: 0 !important
.display-inline
display: inline !important

@ -7,6 +7,8 @@
// Legacy styles, remove if possible
@import openproject/legacy
@import openproject/generic
@import openproject/mixins
@import openproject/onboarding
@import openproject/scm

@ -34,6 +34,7 @@ class AccountController < ApplicationController
include Concerns::RedirectAfterLogin
include Concerns::AuthenticationStages
include Concerns::UserConsent
include Concerns::UserLimits
# prevents login action to be filtered by check_if_login_required application scope filter
skip_before_action :check_if_login_required
@ -171,6 +172,8 @@ class AccountController < ApplicationController
end
def activate_self_registered(token)
return if enforce_activation_user_limit
user = token.user
if not user.registered?
@ -202,6 +205,8 @@ class AccountController < ApplicationController
redirect_to home_url
else
return if enforce_activation_user_limit
activate_invited token
end
end
@ -555,6 +560,12 @@ class AccountController < ApplicationController
#
# Pass a block for behavior when a user fails to save
def register_automatically(user, opts = {})
if user_limit_reached?
show_user_limit_activation_error!
return redirect_back fallback_location: signin_path
end
# Automatic activation
user.activate

@ -0,0 +1,84 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2017 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-2017 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.
#++
##
# Intended to be used by the UsersController to enforce the user limit.
module Concerns::UserLimits
def enforce_user_limit(redirect_to: users_path, hard: OpenProject::Enterprise.fail_fast?)
if user_limit_reached?
if hard
show_user_limit_error!
redirect_back fallback_location: redirect_to
else
show_user_limit_warning!
end
true
else
false
end
end
def enforce_activation_user_limit(redirect_to: signin_path)
if user_limit_reached?
show_user_limit_activation_error!
redirect_back fallback_location: redirect_to
true
else
false
end
end
def show_user_limit_activation_error!
flash[:error] = I18n.t(:error_enterprise_activation_user_limit)
end
def show_user_limit_warning!
flash[:warning] = user_limit_warning
end
def show_user_limit_error!
flash[:error] = user_limit_warning
end
def user_limit_warning
warning = I18n.t(
:warning_user_limit_reached,
upgrade_url: OpenProject::Enterprise.upgrade_path
)
warning.html_safe
end
def user_limit_reached?
OpenProject::Enterprise.user_limit_reached?
end
end

@ -235,7 +235,7 @@ class MembersController < ApplicationController
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?
if current_user.admin? && enterprise_allow_new_users?
# 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) ||
@ -249,6 +249,10 @@ class MembersController < ApplicationController
end.compact
end
def enterprise_allow_new_users?
!OpenProject::Enterprise.user_limit_reached? || !OpenProject::Enterprise.fail_fast?
end
def each_comma_seperated(array, &block)
array.map { |e|
if e.to_s.match /\d(,\d)*/

@ -50,6 +50,9 @@ class UsersController < ApplicationController
include Concerns::PasswordConfirmation
before_action :check_password_confirmation, only: [:destroy]
include Concerns::UserLimits
before_action :enforce_user_limit, only: [:new, :create]
accept_key_auth :index, :show, :create, :update, :destroy
include SortHelper
@ -159,7 +162,13 @@ class UsersController < ApplicationController
if @user.invited?
# setting a password for an invited user activates them implicitly
@user.activate!
if OpenProject::Enterprise.user_limit_reached?
@user.register!
show_user_limit_warning!
else
@user.activate!
end
send_information = true
end
@ -202,6 +211,13 @@ class UsersController < ApplicationController
redirect_back_or_default(action: 'edit', id: @user)
return
end
if (params[:unlock] || params[:activate]) && user_limit_reached?
show_user_limit_error!
return redirect_back_or_default(action: 'edit', id: @user)
end
if params[:unlock]
@user.failed_login_count = 0
@user.activate

@ -2,11 +2,11 @@ module ToolbarHelper
include ERB::Util
include ActionView::Helpers::OutputSafetyHelper
def toolbar(title:, subtitle: '', link_to: nil, html: {})
def toolbar(title:, title_extra: nil, title_class: nil, subtitle: '', link_to: nil, html: {})
classes = ['toolbar-container', html[:class]].compact.join(' ')
content_tag :div, class: classes do
toolbar = content_tag :div, class: 'toolbar' do
dom_title(title, link_to) + dom_toolbar {
dom_title(title, link_to, title_class: title_class, title_extra: title_extra) + dom_toolbar {
yield if block_given?
}
end
@ -21,7 +21,7 @@ module ToolbarHelper
protected
def dom_title(raw_title, link_to = nil)
def dom_title(raw_title, link_to = nil, title_class: nil, title_extra: nil)
title = ''.html_safe
title << raw_title
@ -31,7 +31,13 @@ module ToolbarHelper
end
content_tag :div, class: 'title-container' do
content_tag(:h2, title)
opts = {}
opts[:class] = title_class if title_class.present?
content_tag(:h2, title, opts) + (
title_extra.present? ? title_extra : ''
)
end
end

@ -13,6 +13,14 @@
<span><%= @current_token.mail %></span>
</div>
</div>
<% Hash(@current_token.restrictions).each do |key, value| %>
<div class="attributes-key-value--key"><%= EnterpriseToken.human_attribute_name("#{key}_restriction") %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= value %></span>
</div>
</div>
<% end %>
<div class="attributes-key-value--key"><%= EnterpriseToken.human_attribute_name(:starts_at) %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">

@ -80,4 +80,11 @@ See doc/COPYRIGHT.rdoc for more details.
</div>
</div>
</div>
<% if OpenProject::Enterprise.user_limit_reached? %>
<div class="notification-box -warning icon-warning" id="member-user-limit-warning" style="display: none;">
<div class="notification-box--content">
<p><%= I18n.t(:warning_user_limit_reached, upgrade_url: OpenProject::Enterprise.upgrade_path).html_safe %></p>
</div>
</div>
<% end %>
<% end %>

@ -26,8 +26,15 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See doc/COPYRIGHT.rdoc for more details.
++#%>
<% html_title l(:label_administration), l(:label_user_plural) -%>
<%= toolbar title: l(:label_user_plural) do %>
<% html_title t(:label_administration), t(:label_user_plural) -%>
<% users_info = OpenProject::Enterprise.token && content_tag(:div) do %>
<% token = OpenProject::Enterprise.token %>
<%= t(:label_enterprise_active_users, current: User.active.count, limit: token.restrictions[:active_user_count]) %>
&nbsp;
<a href="<%= OpenProject::Enterprise.upgrade_path %>" class="display-inline button -tiny -highlight" title="<%= t(:title_enterprise_upgrade) %>"><%= t(:button_upgrade) %></a>
<% end %>
<%= toolbar title: t(:label_user_plural), title_class: 'no-padding-bottom', title_extra: users_info do %>
<li class="toolbar-item">
<%= link_to new_user_path,
{ class: 'button -alt-highlight',

@ -314,6 +314,7 @@ en:
expires_at: "Expires at"
subscriber: "Subscriber"
encoded_token: "Enterprise support token"
active_user_count_restriction: "Maximum active users"
relation:
delay: "Delay"
from: "Work package"
@ -703,6 +704,7 @@ en:
button_unlock: "Unlock"
button_unwatch: "Unwatch"
button_update: "Update"
button_upgrade: "Upgrade"
button_upload: "Upload"
button_view: "View"
button_watch: "Watch"
@ -921,6 +923,7 @@ en:
error_cookie_missing: 'The OpenProject cookie is missing. Please ensure that cookies are enabled, as this application will not properly function without.'
error_custom_option_not_found: "Option does not exist."
error_dependent_work_package: "Error in dependent work package #%{related_id} %{related_subject}: %{error}"
error_enterprise_activation_user_limit: "Your account could not be activated (user limit reached). Please contact your administrator to gain access."
error_failed_to_delete_entry: 'Failed to delete this entry.'
error_invalid_group_by: "Can't group by: %{value}"
error_invalid_query_column: "Invalid query column: %{value}"
@ -1178,6 +1181,7 @@ en:
label_enumerations: "Enumerations"
label_enterprise: "Enterprise"
label_enterprise_edition: "Enterprise Edition"
label_enterprise_active_users: "%{current}/%{limit} booked active users"
label_environment: "Environment"
label_estimates_and_time: "Estimates and time"
label_equals: "is"
@ -2354,6 +2358,7 @@ en:
years: "Years"
title_remove_and_delete_user: Remove the invited user from the project and delete him/her.
title_enterprise_upgrade: "Upgrade to unlock more users."
tooltip_resend_invitation: >
Sends another invitation email with a fresh token in
@ -2420,6 +2425,7 @@ en:
note_password_login_disabled: "Password login has been disabled by %{configuration}."
warning: Warning
warning_attachments_not_saved: "%{count} file(s) could not be saved."
warning_user_limit_reached: "User limit reached. Please <a href=\"%{upgrade_url}\">upgrade</a> to allow for additional users."
menu_item: "Menu item"
menu_item_setting: "Visibility"

@ -64,6 +64,7 @@ storage config above like this:
* [`blacklisted_routes`](#blacklisted-routes) (default: [])
* [`global_basic_auth`](#global-basic-auth)
* [`apiv3_enable_basic_auth`](#apiv3_enable_basic_auth)
* [`enterprise_limits`](#enterprise-limits)
## Setting session options
@ -338,3 +339,29 @@ default:
## Onboarding variables:
* 'onboarding_video_url': An URL for the video displayed on the onboarding modal. This is only shown when the user logs in for the first time.
### Enterprise Limits
If using an Enterprise token there are certain limits that apply.
You can configure how these limits are enforced.
#### `fail_fast`
*default: false*
If you set `fail_fast` to true, new users cannot be invited or registered if the user limit has been reached.
If it is false then you can still invite and register new users but their activation will fail until the
user limit has been increased (or the number of active users decreased).
Configured in the `configuration.yml` like this:
```
enterprise:
fail_fast: true
```
Or through the environment like this:
```
OPENPROJECT_ENTERPRISE_FAIL__FAST=true
```

@ -0,0 +1,67 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2017 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-2017 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 OpenProject
module Enterprise
class << self
def token
EnterpriseToken.current.presence
end
def upgrade_path
url_helpers.enterprise_path
end
def user_limit
Hash(token.restrictions)[:active_user_count] if token
end
def active_user_count
User.active.count
end
##
# Indicates if there are more active users than the support token allows for.
#
# @return [Boolean] True if and only if there is a support token the user limit of which is exceeded.
def user_limit_reached?
active_user_count >= user_limit if user_limit
end
def fail_fast?
Hash(OpenProject::Configuration["enterprise"])["fail_fast"]
end
private
def url_helpers
@url_helpers ||= OpenProject::StaticRouting::StaticUrlHelpers.new
end
end
end
end

@ -445,6 +445,30 @@ describe AccountController, type: :controller do
expect(user.status).to eq(User::STATUSES[:active])
end
end
context "with user limit reached" do
before do
allow(OpenProject::Enterprise).to receive(:user_limit_reached?).and_return(true)
end
it "fails" do
post :register,
params: {
user: {
login: 'register',
password: 'adminADMIN!',
password_confirmation: 'adminADMIN!',
firstname: 'John',
lastname: 'Doe',
mail: 'register@example.com'
}
}
is_expected.to redirect_to(signin_path)
expect(flash[:error]).to match /user limit reached/
end
end
end
context 'with password login disabled' do
@ -642,6 +666,42 @@ describe AccountController, type: :controller do
end
end
context 'POST activate' do
let(:user) { FactoryGirl.create :user, status: status }
let(:status) { -1 }
let(:token) { Token::Invitation.create!(user_id: user.id) }
before do
allow(OpenProject::Enterprise).to receive(:user_limit_reached?).and_return(true)
post :activate, params: { token: token.value }
end
shared_examples "activation is blocked due to user limit" do
it "does not activate the user" do
expect(user.reload).not_to be_active
end
it "redirects back to the login page and shows the user limit error" do
expect(response).to redirect_to(signin_path)
expect(flash[:error]).to match /user limit reached.*contact.*admin/i
end
end
context 'registered user' do
let(:status) { User::STATUSES[:registered] }
it_behaves_like "activation is blocked due to user limit"
end
context 'invited user' do
let(:status) { User::STATUSES[:invited] }
it_behaves_like "activation is blocked due to user limit"
end
end
describe 'GET #auth_source_sso_failed (/sso)' do
render_views

@ -49,6 +49,48 @@ describe UsersController, type: :controller do
let(:admin) { FactoryGirl.create(:admin) }
let(:anonymous) { FactoryGirl.create(:anonymous) }
describe 'GET new' do
context "with user limit reached" do
before do
allow(OpenProject::Enterprise).to receive(:user_limit_reached?).and_return(true)
end
context "with fail fast" do
before do
allow(OpenProject::Enterprise).to receive(:fail_fast?).and_return(true)
as_logged_in_user admin do
get :new
end
end
it "shows a user limit error" do
expect(flash[:error]).to match /user limit reached/i
end
it "redirects back to the user index" do
expect(response).to redirect_to users_path
end
end
context "without fail fast" do
before do
as_logged_in_user admin do
get :new
end
end
it "shows a user limit warning" do
expect(flash[:warning]).to match /user limit reached/i
end
it "shows the new user page" do
expect(response).to render_template("users/new")
end
end
end
end
describe 'GET deletion_info' do
describe "WHEN the current user is the requested user
WHEN the setting users_deletable_by_self is set to true" do
@ -364,7 +406,11 @@ describe UsersController, type: :controller do
language: 'de')
end
let(:user_limit_reached) { false }
before do
allow(OpenProject::Enterprise).to receive(:user_limit_reached?).and_return(user_limit_reached)
as_logged_in_user admin do
post :change_status,
params: {
@ -388,6 +434,18 @@ describe UsersController, type: :controller do
locale: 'de'))
end
end
context "with user limit reached" do
let(:user_limit_reached) { true }
it "shows the user limit reached error and recommends to upgrade" do
expect(flash[:error]).to match /user limit reached.*upgrade/i
end
it "does not activate the user" do
expect(registered_user.reload).not_to be_active
end
end
end
end

@ -0,0 +1,80 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2017 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-2017 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'
require 'open_project/passwords'
describe OpenProject::Enterprise do
describe "#user_limit_reached?" do
let(:user_limit) { 2 }
before do
allow(OpenProject::Enterprise).to receive(:user_limit).and_return(user_limit)
end
context "with fewer active users than the limit allows" do
before do
FactoryGirl.create :user
expect(User.active.count).to eq 1
end
it "is false" do
expect(subject).not_to be_user_limit_reached
end
end
context "with equal or more active users than the limit allows" do
shared_examples "user limit is reached" do
let(:num_active_users) { 0 }
before do
(1..num_active_users).each { |_i| FactoryGirl.create :user }
expect(User.active.count).to eq num_active_users
end
it "is true" do
expect(subject).to be_user_limit_reached
end
end
context "(equal)" do
it_behaves_like "user limit is reached" do
let(:num_active_users) { user_limit }
end
end
context "(more)" do
it_behaves_like "user limit is reached" do
let(:num_active_users) { user_limit + 1 }
end
end
end
end
end

@ -0,0 +1,73 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2017 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-2017 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'
describe 'users/index', type: :view do
let!(:admin) { FactoryGirl.create :admin }
let!(:user) { FactoryGirl.create :user, firstname: "Scarlet", lastname: "Scallywag" }
before do
assign(:users, [admin, user])
assign(:status, "all")
assign(:groups, Group.all)
allow(view).to receive(:current_user).and_return(admin)
allow_any_instance_of(TableCell).to receive(:controller_name).and_return("users")
allow_any_instance_of(TableCell).to receive(:action_name).and_return("index")
end
it 'renders the user table' do
render
expect(rendered).to have_text(admin.name)
expect(rendered).to have_text("Scarlet Scallywag")
end
context "with an Enterprise token" do
before do
allow(OpenProject::Enterprise).to receive(:token).and_return(Struct.new(:restrictions).new({active_user_count: 5}))
end
it "shows the current number of active and allowed users" do
render
# expected active users: admin and user from above
expect(rendered).to have_text("2/5 booked active users")
end
end
context "without an Enterprise token" do
it "does not show the current number of active and allowed users" do
render
expect(rendered).not_to have_text("booked active users")
end
end
end
Loading…
Cancel
Save