Merge pull request #10063 from opf/feature/40546-global-roles-for-groups

[#40546] Global roles for groups
pull/10147/head
Christophe Bliard 3 years ago committed by GitHub
commit 2221e2998b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      .github/workflows/pullpreview.yml
  2. 1
      .rubocop.yml
  3. 19
      app/controllers/groups_controller.rb
  4. 23
      app/helpers/groups_helper.rb
  5. 34
      app/helpers/members_helper.rb
  6. 4
      app/mailers/member_mailer.rb
  7. 9
      app/models/member.rb
  8. 3
      app/models/member_role.rb
  9. 73
      app/models/members/roles_diff.rb
  10. 6
      app/models/permitted_params.rb
  11. 15
      app/models/principal.rb
  12. 2
      app/models/principals/scopes/visible.rb
  13. 2
      app/models/queries/members/filters/principal_filter.rb
  14. 6
      app/services/groups/add_users_service.rb
  15. 7
      app/services/groups/concerns/membership_manipulation.rb
  16. 2
      app/services/groups/update_roles_service.rb
  17. 26
      app/views/groups/_memberships.html.erb
  18. 2
      app/views/groups/_users.html.erb
  19. 2
      app/views/groups/edit.html.erb
  20. 10
      app/views/individual_principals/_memberships.html.erb
  21. 2
      app/views/principals/_assigned_global_role.html.erb
  22. 0
      app/views/principals/_available_global_role.html.erb
  23. 13
      app/views/principals/_available_global_roles.html.erb
  24. 13
      app/views/principals/_global_roles.html.erb
  25. 0
      app/views/principals/_global_roles_header.html.erb
  26. 3
      app/views/roles/_form.html.erb
  27. 16
      app/workers/mails/member_created_job.rb
  28. 39
      app/workers/mails/member_job.rb
  29. 14
      app/workers/notifications/group_member_altered_job.rb
  30. 11
      config/routes.rb
  31. 8
      docs/api/apiv3/paths/memberships.yml
  32. 12
      docs/system-admin-guide/users-permissions/groups/README.md
  33. BIN
      docs/system-admin-guide/users-permissions/groups/image-20210302095755016.png
  34. BIN
      docs/system-admin-guide/users-permissions/groups/image-20210505162541644.png
  35. 12
      docs/system-admin-guide/users-permissions/roles-permissions/README.md
  36. 2
      lib/open_project/ui/extensible_tabs.rb
  37. 55
      spec/controllers/groups_controller_spec.rb
  38. 40
      spec/mailers/member_mailer_spec.rb
  39. 17
      spec/models/member_spec.rb
  40. 207
      spec/models/members/roles_diff_spec.rb
  41. 8
      spec/models/principals/scopes/visible_spec.rb
  42. 2
      spec/models/queries/members/filters/principal_filter_spec.rb
  43. 116
      spec/requests/api/v3/membership_resources_spec.rb
  44. 49
      spec/services/groups/add_users_service_integration_spec.rb
  45. 26
      spec/services/groups/cleanup_inherited_roles_service_integration_spec.rb
  46. 75
      spec/services/groups/update_roles_service_integration_spec.rb
  47. 2
      spec/support/pages/groups.rb
  48. 76
      spec/workers/mails/member_created_job_spec.rb
  49. 71
      spec/workers/mails/member_updated_job_spec.rb
  50. 14
      spec/workers/notifications/group_member_altered_job_spec.rb

@ -28,7 +28,7 @@ jobs:
cp ./docker/prod/Dockerfile ./Dockerfile
- uses: pullpreview/action@v5
with:
admins: crohr,HDinger,machisuji,oliverguenther,ulferts,wielinde,b12f
admins: crohr,HDinger,machisuji,oliverguenther,ulferts,wielinde,b12f,cbliard
always_on: dev
compose_files: docker-compose.pullpreview.yml
instance_type: medium_2_0

@ -198,6 +198,7 @@ RSpec/NamedSubject:
RSpec/ContextWording:
Prefixes:
- as
- when
- with
- without

@ -75,8 +75,6 @@ class GroupsController < ApplicationController
# GET /groups/1/edit
def edit
@group = Group.includes(:members, :users).find(params[:id])
set_filters_for_user_autocompleter
end
# POST /groups
@ -154,7 +152,7 @@ class GroupsController < ApplicationController
end
def create_memberships
membership_params = permitted_params.group_membership[:new_membership]
membership_params = permitted_params.group_membership[:membership]
service_call = Members::CreateService
.new(user: current_user)
@ -176,12 +174,13 @@ class GroupsController < ApplicationController
end
def destroy_membership
member = Member.find(params[:membership_id])
Members::DeleteService
.new(model: Member.find(params[:membership_id]), user: current_user)
.new(model: member, user: current_user)
.call
flash[:notice] = I18n.t :notice_successful_delete
redirect_to controller: '/groups', action: 'edit', id: @group, tab: 'memberships'
redirect_to controller: '/groups', action: 'edit', id: @group, tab: redirected_to_tab(member)
end
protected
@ -222,7 +221,15 @@ class GroupsController < ApplicationController
flash[:error] = service_call.errors.full_messages.join("\n")
end
redirect_to controller: '/groups', action: 'edit', id: @group, tab: 'memberships'
redirect_to controller: '/groups', action: 'edit', id: @group, tab: redirected_to_tab(service_call.result)
end
def redirected_to_tab(membership)
if membership.project
'memberships'
else
'global_roles'
end
end
def respond_users_altered(service_call)

@ -29,32 +29,39 @@
#++
module GroupsHelper
def group_settings_tabs
def group_settings_tabs(group)
[
{
name: 'general',
partial: 'groups/general',
path: edit_group_path(@group),
path: edit_group_path(group),
label: :label_general
},
{
name: 'users',
partial: 'groups/users',
path: edit_group_path(@group, tab: :users),
path: edit_group_path(group, tab: :users),
label: :label_user_plural
},
{
name: 'memberships',
partial: 'groups/memberships',
path: edit_group_path(@group, tab: :memberships),
path: edit_group_path(group, tab: :memberships),
label: :label_project_plural
},
{
name: 'global_roles',
partial: 'principals/global_roles',
path: edit_group_path(group, tab: :global_roles),
label: :label_global_roles
}
]
end
def set_filters_for_user_autocompleter
@autocompleter_filters = []
@autocompleter_filters.push({ selector: 'status', operator: '=', values: ['active', 'invited'] })
@autocompleter_filters.push({ selector: 'group', operator: '!', values: [@group.id] })
def autocompleter_filters(group)
[
{ selector: 'status', operator: '=', values: ['active', 'invited'] },
{ selector: 'group', operator: '!', values: [group.id] }
]
end
end

@ -37,12 +37,42 @@ module MembersHelper
def global_member_role_deletion_link(member, role)
if member.roles.length == 1
link_to('',
user_membership_path(user_id: member.user_id, id: member.id),
principal_membership_path(member.principal, member),
{ method: :delete, class: 'icon icon-delete', title: t(:button_delete) })
else
link_to('',
user_membership_path(user_id: member.user_id, id: member.id, 'membership[role_ids]' => member.roles - [role]),
principal_membership_path(member.principal, member, 'membership[role_ids]' => member.roles - [role]),
{ method: :patch, class: 'icon icon-delete', title: t(:button_delete) })
end
end
##
# Decorate the form_for helper for membership of a user or a group to a global
# role.
def global_role_membership_form_for(principal, global_member, options = {}, &block)
args =
if global_member
{ url: principal_membership_path(principal, global_member), method: :patch }
else
{ url: principal_memberships_path(principal), method: :post }
end
form_for(:principal_roles, args.merge(options), &block)
end
def principal_membership_path(principal, global_member, options = {})
if principal.is_a?(Group)
membership_of_group_path(principal, global_member, options)
else
user_membership_path(principal, global_member, options)
end
end
def principal_memberships_path(principal, options = {})
if principal.is_a?(Group)
memberships_of_group_path(principal, options)
else
user_memberships_path(principal, options)
end
end
end

@ -78,8 +78,8 @@ class MemberMailer < ApplicationMailer
end
def send_mail(current_user, member, subject, message)
in_member_locale(member) do
User.execute_as(current_user) do
User.execute_as(current_user) do
in_member_locale(member) do
message_id member, current_user
@roles = member.roles

@ -34,7 +34,7 @@ class Member < ApplicationRecord
extend DeprecatedAlias
belongs_to :principal, foreign_key: 'user_id'
has_many :member_roles, dependent: :destroy, autosave: true, validate: false
has_many :roles, through: :member_roles
has_many :roles, -> { distinct }, through: :member_roles
belongs_to :project
validates_presence_of :principal
@ -70,6 +70,13 @@ class Member < ApplicationRecord
member_roles.detect(&:inherited_from).nil?
end
def deletable_role?(role)
member_roles
.only_inherited
.where(role: role)
.none?
end
def include?(principal)
if user?
self.principal == principal

@ -32,6 +32,9 @@ class MemberRole < ApplicationRecord
belongs_to :member, touch: true
belongs_to :role
# `inherited` is reserved ActiveRecord method
scope :only_inherited, -> { where.not(inherited_from: nil) }
validates_presence_of :role
validate :validate_project_member_role

@ -0,0 +1,73 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# 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 COPYRIGHT and LICENSE files for more details.
#++
class Members::RolesDiff
attr_reader :user_member, :group_member
def initialize(user_member, group_member)
raise ArgumentError unless user_member.project_id == group_member.project_id
@user_member = user_member
@group_member = group_member
end
def roles_created?
result == :roles_created
end
def roles_updated?
result == :roles_updated
end
def roles_changed?
result != :roles_unchanged
end
def result
@result ||=
if user_previous_member_roles_ids.empty?
:roles_created
elsif (group_roles_ids - user_previous_member_roles_ids).any?
:roles_updated
else
:roles_unchanged
end
end
private
def user_previous_member_roles_ids
Set.new(user_member.member_roles
.reject { group_member.member_roles.map(&:id).include?(_1.inherited_from) }
.map(&:role_id).uniq)
end
def group_roles_ids
Set.new(group_member.member_roles.map(&:role_id))
end
end

@ -490,11 +490,7 @@ class PermittedParams
{ membership: [
:project_id,
{ role_ids: [] }
],
new_membership: [
:project_id,
{ role_ids: [] }
] }
] }
],
member: [
role_ids: []

@ -47,12 +47,15 @@ class Principal < ApplicationRecord
class_name: 'UserPreference',
foreign_key: 'user_id'
has_many :members, foreign_key: 'user_id', dependent: :destroy
has_many :memberships, -> {
includes(:project, :roles)
.where(["projects.active = ? OR project_id IS NULL", true])
.order(Arel.sql('projects.name ASC'))
# haven't been able to produce the order using hashes
},
has_many :memberships,
-> {
includes(:project, :roles)
.where(["projects.active = ? OR project_id IS NULL", true])
.order(Arel.sql('projects.name ASC'))
# haven't been able to produce the order using hashes
},
inverse_of: :principal,
dependent: :nullify,
class_name: 'Member',
foreign_key: 'user_id'
has_many :projects, through: :memberships

@ -38,7 +38,7 @@ module Principals::Scopes
class_methods do
def visible(user = ::User.current)
if user.allowed_to_globally?(:manage_members)
if user.allowed_to_globally?(:manage_members) || user.allowed_to_globally?(:manage_user)
all
else
in_visible_project_or_me(user)

@ -35,7 +35,7 @@ class Queries::Members::Filters::PrincipalFilter < Queries::Members::Filters::Me
@allowed_values ||= begin
values = Principal
.not_locked
.in_visible_project_or_me
.visible
.map { |s| [s.name, s.id.to_s] }
.sort

@ -85,7 +85,7 @@ module Groups
SELECT members.id, found_users.user_id, members.project_id
FROM members, found_users, group_memberships
WHERE members.user_id = found_users.user_id
AND members.project_id = group_memberships.project_id
AND members.project_id IS NOT DISTINCT FROM group_memberships.project_id
AND members.id IS NOT NULL
),
-- insert the group user into members
@ -93,7 +93,7 @@ module Groups
INSERT INTO #{Member.table_name} (project_id, user_id, updated_at, created_at)
SELECT group_memberships.project_id, found_users.user_id, (SELECT time from timestamp), (SELECT time from timestamp)
FROM found_users, group_memberships
WHERE NOT EXISTS (SELECT 1 FROM existing_members WHERE existing_members.user_id = found_users.user_id AND existing_members.project_id = group_memberships.project_id)
WHERE NOT EXISTS (SELECT 1 FROM existing_members WHERE existing_members.user_id = found_users.user_id AND existing_members.project_id IS NOT DISTINCT FROM group_memberships.project_id)
ON CONFLICT(project_id, user_id) DO NOTHING
RETURNING id, user_id, project_id
),
@ -103,7 +103,7 @@ module Groups
SELECT members.id, group_roles.role_id, group_roles.member_role_id
FROM group_roles
JOIN
(SELECT * FROM new_members UNION SELECT * from existing_members) members ON group_roles.project_id = members.project_id
(SELECT * FROM new_members UNION SELECT * from existing_members) members ON group_roles.project_id IS NOT DISTINCT FROM members.project_id
-- Ignore if the role was already inserted by us
ON CONFLICT DO NOTHING
RETURNING id, member_id, role_id

@ -78,7 +78,12 @@ module Groups::Concerns
end
def send_notifications(member_ids, message, send_notifications)
Notifications::GroupMemberAlteredJob.perform_later(member_ids, message, send_notifications)
Notifications::GroupMemberAlteredJob.perform_later(
User.current,
member_ids,
message,
send_notifications
)
end
end
end

@ -68,7 +68,7 @@ module Groups
SELECT id
FROM #{Member.table_name}
WHERE user_id IN (SELECT user_id FROM group_users)
AND project_id = :project_id
AND project_id IS NOT DISTINCT FROM :project_id
),
-- select all member roles the group has for the member
group_member_roles AS (

@ -28,6 +28,7 @@ See COPYRIGHT and LICENSE files for more details.
++#%>
<% roles = Role.givable %>
<% projects = Project.active.order(Arel.sql('lft')) %>
<% memberships = @group.memberships %>
<div class="grid-block">
<div class="grid-content">
@ -64,15 +65,19 @@ See COPYRIGHT and LICENSE files for more details.
</tr>
</thead>
<tbody>
<% @group.memberships.each do |membership| %>
<% memberships.where.not(project: nil).each do |membership| %>
<% next if membership.new_record? %>
<tr id="member-<%= membership.id %>" class="class">
<td class="project"><%= link_to membership.project.name, project_members_path(membership.project) %></td>
<tr id="member-<%= membership.id %>" class="member">
<td class="project">
<%= link_to membership.project.name, project_members_path(membership.project) %>
</td>
<td class="roles">
<span id="member-<%= membership.id %>-roles"><%=h membership.roles.sort.collect(&:to_s).join(', ') %></span>
<span id="member-<%= membership.id %>-roles">
<%=h membership.roles.sort.collect(&:to_s).join(', ') %>
</span>
<%= labelled_tabular_form_for(:membership,
url: membership_of_group_path(@group, membership),
method: :put,
method: :patch,
html: {
id: "member-#{membership.id}-roles-form",
style: 'display:none;'
@ -83,7 +88,8 @@ See COPYRIGHT and LICENSE files for more details.
role.id,
membership.roles.include?(role),
role.name,
no_label: true
no_label: true,
id: nil
%>
<%= role %>
</label>
@ -104,8 +110,8 @@ See COPYRIGHT and LICENSE files for more details.
class: 'icon icon-remove' %>
</td>
</tr>
<% end %>
</tbody>
<% end %>
</table>
</div>
@ -119,12 +125,12 @@ See COPYRIGHT and LICENSE files for more details.
<%= styled_form_tag(memberships_of_group_path(@group), method: :post) do %>
<fieldset class="form--fieldset add-membership-form-fieldset">
<legend class="form--fieldset-legend"><%=t(:label_project_new)%></legend>
<%= label_tag "new_membership_project_id", t(:description_choose_project), class: "hidden-for-sighted" %>
<%= styled_select_tag 'new_membership[project_id]', options_for_membership_project_select(@group, projects) %>
<%= label_tag "membership_project_id", t(:description_choose_project), class: "hidden-for-sighted" %>
<%= styled_select_tag 'membership[project_id]', options_for_membership_project_select(@group, projects) %>
<div class="form--field -vertical">
<%= styled_label_tag nil, "#{t(:label_role_plural)}:" %>
<div class="form--field-container -vertical">
<%= labeled_check_box_tags 'new_membership[role_ids][]', roles %>
<%= labeled_check_box_tags 'membership[role_ids][]', roles %>
</div>
</div>
<div><%= styled_button_tag t(:button_add), class: '-highlight -with-icon icon-checkmark' %></div>

@ -55,7 +55,7 @@ See COPYRIGHT and LICENSE files for more details.
<div class="form--field -vertical">
<%= hidden_field_tag :user_ids, nil %>
<user-autocompleter data-update-input="user_ids"
data-additional-filter="<%= @autocompleter_filters&.to_json %>"
data-additional-filter="<%= autocompleter_filters(@group).to_json %>"
class="new-group-members--autocomplete">
</user-autocompleter>
</div>

@ -50,4 +50,4 @@ See COPYRIGHT and LICENSE files for more details.
</li>
<% end %>
<% end %>
<%= render_tabs group_settings_tabs %>
<%= render_tabs group_settings_tabs(@group) %>

@ -83,10 +83,12 @@ See COPYRIGHT and LICENSE files for more details.
</span>
<%= labelled_tabular_form_for(:membership,
url: polymorphic_path([@individual_principal, :membership], id: membership),
html: { id: "member-#{membership.id}-roles-form",
class: "member-#{membership.id}--edit-toggle-item",
style: 'display:none;'},
method: :patch) do |f| %>
method: :patch,
html: {
id: "member-#{membership.id}-roles-form",
class: "member-#{membership.id}--edit-toggle-item",
style: 'display:none;'
}) do |f| %>
<div>
<% roles.each do |role| %>
<label class="form--label-with-check-box">

@ -32,6 +32,6 @@ See COPYRIGHT and LICENSE files for more details.
<%=h role %>
</td>
<td class="buttons">
<%= global_member_role_deletion_link(member, role) %>
<%= global_member_role_deletion_link(member, role) if member.deletable_role?(role) %>
</td>
</tr>

@ -31,21 +31,14 @@ See COPYRIGHT and LICENSE files for more details.
<div class="grid-content" id="available_principal_roles">
<fieldset class="form--fieldset">
<legend class="form--fieldset-legend"><%= Role.model_name.human(:count => 2) %></legend>
<legend class="form--fieldset-legend"><%= Role.model_name.human(count: 2) %></legend>
<% if available_roles.empty? %>
<span id="no_additional_principal_roles">
<%= no_results_box %>
</span>
<% else %>
<span id="additional_principal_roles">
<% args =
if global_member
{ url: user_membership_path(id: global_member.id, user_id: user.id), method: :patch }
else
{ url: user_memberships_path(user_id: user.id), method: :post }
end
%>
<%= form_for(:principal_roles, **args) do %>
<%= global_role_membership_form_for(principal, global_member) do %>
<% if global_member %>
<%= hidden_field_tag('membership[id]', global_member.id ) %>
@ -55,7 +48,7 @@ See COPYRIGHT and LICENSE files for more details.
<% end %>
<% available_roles.each do |role| %>
<%= render :partial => 'users/available_global_role', :locals => {:role => role} %>
<%= render partial: 'principals/available_global_role', locals: {role: role} %>
<% end %>
<p><br/><%= styled_button_tag t(:button_add), class: '-with-icon icon-checkmark' %></p>
<% end %>

@ -27,9 +27,12 @@ See COPYRIGHT and LICENSE files for more details.
++#%>
<%= render partial: 'global_roles_header' %>
<%= render partial: 'principals/global_roles_header' %>
<% global_member = Member.global.where(principal: @user).includes(:roles).first %>
<%
principal = @group || @user
global_member = Member.global.where(principal: principal).includes(:roles).first
%>
<div class="grid-block" id="principal_global_roles_content">
<div id="assigned_principal_roles" class="grid-content">
@ -59,8 +62,8 @@ See COPYRIGHT and LICENSE files for more details.
</thead>
<tbody id="table_principal_roles_body">
<% global_member.roles.each do |role| %>
<%= render :partial => 'assigned_global_role', :locals => { :role => role, member: global_member } %>
<%end%>
<%= render partial: 'principals/assigned_global_role', locals: { role: role, member: global_member } %>
<% end %>
</tbody>
</table>
@ -69,5 +72,5 @@ See COPYRIGHT and LICENSE files for more details.
<% end %>
</div>
<%= render partial: 'users/available_global_roles', locals: { user: @user, global_member: global_member }%>
<%= render partial: 'principals/available_global_roles', locals: { principal: principal, global_member: global_member } %>
</div>

@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details.
++#%>
<%= render :partial => 'users/global_roles_header' %>
<%= render partial: 'principals/global_roles_header' %>
<% roles ||= nil %>
@ -66,4 +66,3 @@ See COPYRIGHT and LICENSE files for more details.
<%= render partial: "permissions", locals: {permissions: grouped_setable_permissions(role), role: role, showGlobalRole: false }%>
</div>
<% end %>

@ -32,19 +32,11 @@ class Mails::MemberCreatedJob < Mails::MemberJob
alias_method :send_for_project_user, :send_added_project
def send_for_group_user(current_user, user_member, group_member, message)
if new_roles_added?(user_member, group_member)
send_updated_project(current_user, user_member, message)
elsif all_roles_added?(user_member, group_member)
difference = Members::RolesDiff.new(user_member, group_member)
if difference.roles_created?
send_added_project(current_user, user_member, message)
elsif difference.roles_updated?
send_updated_project(current_user, user_member, message)
end
end
def new_roles_added?(user_member, group_member)
(group_member.member_roles.map(&:id) - user_member.member_roles.map(&:inherited_from)).length <
group_member.member_roles.length && user_member.member_roles.any? { |mr| mr.inherited_from.nil? }
end
def all_roles_added?(user_member, group_member)
(user_member.member_roles.map(&:inherited_from) - group_member.member_roles.map(&:id)).empty?
end
end

@ -32,18 +32,39 @@ class Mails::MemberJob < ApplicationJob
def perform(current_user:,
member:,
message: nil)
if member.project.nil?
send_updated_global(current_user, member, message)
elsif member.principal.is_a?(Group)
every_group_user_member(member) do |user_member|
case member.principal
when Group
perform_for_group(current_user: current_user, member: member, message: message)
when User
perform_for_user(current_user: current_user, member: member, message: message)
end
end
private
def perform_for_group(current_user:,
member:,
message: nil)
every_group_user_member(member) do |user_member|
if member.project.nil?
next unless roles_changed?(user_member, member)
send_updated_global(current_user, user_member, message)
else
send_for_group_user(current_user, user_member, member, message)
end
elsif member.principal.is_a?(User)
send_for_project_user(current_user, member, message)
end
end
private
def perform_for_user(current_user:,
member:,
message: nil)
if member.project.nil?
send_updated_global(current_user, member, message)
else
send_for_project_user(current_user, member, message)
end
end
def send_for_group_user(_current_user, _member, _group, _message)
raise NotImplementedError, "subclass responsibility"
@ -95,4 +116,8 @@ class Mails::MemberJob < ApplicationJob
.where(project_id: nil, user_id: user_id)
.exists?("membership_#{setting}" => false)
end
def roles_changed?(user_member, group_member)
Members::RolesDiff.new(user_member, group_member).roles_changed?
end
end

@ -31,12 +31,14 @@
class Notifications::GroupMemberAlteredJob < ApplicationJob
queue_with_priority :notification
def perform(members_ids, message, send_notifications)
each_member(members_ids) do |member|
OpenProject::Notifications.send(event_type(member),
member: member,
message: message,
send_notifications: send_notifications)
def perform(current_user, members_ids, message, send_notifications)
User.execute_as(current_user) do
each_member(members_ids) do |member|
OpenProject::Notifications.send(event_type(member),
member: member,
message: message,
send_notifications: send_notifications)
end
end
end

@ -363,12 +363,13 @@ OpenProject::Application.routes.draw do
resources :groups, except: %i[show] do
member do
# this should be put into it's own resource
match '/members' => 'groups#add_users', via: :post, as: 'members_of'
match '/members/:user_id' => 'groups#remove_user', via: :delete, as: 'member_of'
post '/members' => 'groups#add_users', as: 'members_of'
delete '/members/:user_id' => 'groups#remove_user', as: 'member_of'
# this should be put into it's own resource
match '/memberships/:membership_id' => 'groups#edit_membership', via: :put, as: 'membership_of'
match '/memberships/:membership_id' => 'groups#destroy_membership', via: :delete
match '/memberships' => 'groups#create_memberships', via: :post, as: 'memberships_of'
patch '/memberships/:membership_id' => 'groups#edit_membership', as: 'membership_of'
put '/memberships/:membership_id' => 'groups#edit_membership'
delete '/memberships/:membership_id' => 'groups#destroy_membership'
post '/memberships' => 'groups#create_memberships', as: 'memberships_of'
end
end

@ -26,7 +26,13 @@ get:
+ created_at: filters memberships based on the time the membership was created.
+ updated_at: filters memberships based on the time the membership was updated last.
example: '[{ "name": { "operator": "=", "values": ["A User"] }" }]'
examples:
name-filter:
summary: Filtering on the name of the principal
value: '[{ "name": { "operator": "=", "values": ["A User"] }" }]'
global-memberships:
summary: Get memberships for global roles
value: '[{ "project": { "operator": "!*", "values": null }" }]'
in: query
name: filters
required: false

@ -55,6 +55,12 @@ Removing a user from a group removes the role from that user in any project usin
Click the **Projects** tab. Select the projects you want to add this group to from the "New project" drop-down list. Tick the roles that you want the group to have. Click the blue **Add** button. Users in the group are added to that project's members using the role selected.
### Add global roles to a group
Click the **Global Roles** tab. Select the global roles you want to add to this group. Click the **Add** button.
In order to add a global role to a group, at least one global role needs to be [created](../roles-permissions) in the system (a role with the "Global role" field ticked).
### Delete a group
To delete a group click on the **delete** icon in the respective line of the group list.
@ -65,12 +71,12 @@ Deleting a group removes the role from the members of any project using that gro
Groups impact [project members lists](../../../getting-started/invite-members) and [user details](../users). Changes in groups, project members or users may affect the other two.
Find out more about the behavior of groups as project members from a project admin's perspective [here](../../../getting-started/invite-members/#behavior-of-groups-as-project-members).
Find out more about the behavior of groups as project members from a project admin's perspective [here](../../../getting-started/invite-members/#behavior-of-groups-as-project-members).
## Group profile
Similar to users, groups have a profile page which shows their name and members. Each member of a group is only visible for users with the necessary permissions (e.g. user has permission to see this member in a common project or user is system administrator).
Similar to users, groups have a profile page which shows their name and members. Each member of a group is only visible for users with the necessary permissions (e.g. user has permission to see this member in a common project or user is system administrator).
![group-profile-page](image-20210302144820982.png)
The profile page can be accessed via the group's [settings page](#add-users-to-a-group-edit-or-remove-groups), via the overview page of projects the group is a member of and via [mentions](../../../user-guide/work-packages/edit-work-package/#-notification-mention) of the group.
The profile page can be accessed via the group's [settings page](#add-users-to-a-group-edit-or-remove-groups), via the overview page of projects the group is a member of and via [mentions](../../../user-guide/work-packages/edit-work-package/#-notification-mention) of the group.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 21 KiB

@ -10,7 +10,7 @@ keywords: manage roles, manage permissions
A **role** is a set of **permissions** that can be assigned to any project member. Multiple roles can be assigned to the same project member.
When creating a role, the "Global role" field can be ticked, making it a **Global role** that can be assigned to a [users details](../users/#manage-user-settings) and applied across all projects.
When creating a role, the "Global role" field can be ticked, making it a **Global role** that can be assigned to a [user details](../users/#manage-user-settings) or a [group details](../groups/#add-global-roles-to-a-group) and applied across all projects.
| Topic | Content |
@ -53,7 +53,7 @@ After clicking the green **+ Role** button, a form will be shown to define the r
Complete the following as required:
1. **Role name** - must be entered and be a new name.
2. **Global Role** - this role applies to all projects, and can only be assigned in the [user details](../users/#manage-user-settings). Once saved, the decision to make a role a "global role" can't be reverted.
2. **Global Role** - this role applies to all projects, and can be assigned in the [user details](../users/#manage-user-settings) or in the [group details](../groups/#add-global-roles-to-a-group). Once saved, the decision to make a role a "global role" can't be reverted.
Ticking this box will show the available [global roles](#global-roles) and hide the regular permission options.
3. **Work packages...** - tick to allow work packages to be assigned to a user with this role. This does not appear for global roles.
4. **Copy workflow from** - select an existing role. The respective [workflows](../../manage-work-packages/work-package-workflows) will be copied to the role to be created.
@ -77,7 +77,7 @@ To remove an existing role click on the delete icon next to a role in the list (
## Global roles
To create a global role tick the box "Global Role" when [creating a new role](#create-a-new-role).
To create a global role tick the box "Global Role" when [creating a new role](#create-a-new-role).
![global-roles-in-openproject](image-20210308171607279.png)
@ -85,11 +85,11 @@ You can choose between these global permissions:
- **Create project**: With this permission users can create new projects even when they are not system administrators.
[Here](../../system-settings/project-system-settings/#settings-for-new-projects) you can set a default role that users with this permission will have in a project they created.
- **Create and edit users**: Assign this permission to users who should be able to create or invite new users. They also can edit user profiles in a limited way.
Users with this permission can add users and edit a user's name, username, email address and language. Additionally, they can can see all users of your OpenProject instance. They can't delete or lock users.
- **Create and edit users**: Assign this permission to users who should be able to create or invite new users. They also can edit user profiles in a limited way.
Users with this permission can add users and edit a user's name, username, email address and language. Additionally, they can can see all users of your OpenProject instance. They can't delete or lock users.
They can only see the project membership of users for projects in which they have permission to see the members (e.g. as Project admin or Member). They can only manage project membership of users for projects in which they have permission to manage members (e.g. as Project admin).
The user profile will look like this for them (user name and email address were redacted): ![create-and-edit-users-role](image-20210308180635158.png)
- **Create, edit, and delete placeholder users**: Assign this permission to users (e.g. project admins) who should be able to manage [placeholder users](../placeholder-users).
- **Create, edit, and delete placeholder users**: Assign this permission to users (e.g. project admins) who should be able to manage [placeholder users](../placeholder-users).
Users with this permission can create, edit and delete placeholder users, as well as see all placeholder users in your OpenProject instance.
**Please note**: They can only see the project membership of placeholder users for projects in which they have permission to see the members (e.g. as Project admin or Member). They can only manage project membership of placeholder users for projects in which they have permission to manage members (e.g. as Project admin).
A placeholder user's profile will look like this for them: ![create-edit-and-delete-placeholder-users-role](image-20210308192119584.png)

@ -79,7 +79,7 @@ module OpenProject
},
{
name: 'global_roles',
partial: 'users/global_roles',
partial: 'principals/global_roles',
path: ->(params) { edit_user_path(params[:user], tab: :global_roles) },
label: :label_global_roles,
only_if: ->(*) { User.current.admin? }

@ -41,39 +41,39 @@ describe GroupsController, type: :controller do
shared_let(:admin) { create :admin }
let(:current_user) { admin }
it 'should index' do
it 'indexes' do
get :index
expect(response).to be_successful
expect(response).to render_template 'index'
end
it 'should show' do
it 'shows' do
get :show, params: { id: group.id }
expect(response).to be_successful
expect(response).to render_template 'show'
end
it 'should new' do
it 'shows new' do
get :new
expect(response).to be_successful
expect(response).to render_template 'new'
end
it 'should create' do
it 'creates' do
expect do
post :create, params: { group: { lastname: 'New group' } }
end.to change { Group.count }.by(1)
end.to change(Group, :count).by(1)
expect(response).to redirect_to groups_path
end
it 'should edit' do
it 'edits' do
get :edit, params: { id: group.id }
expect(response).to be_successful
expect(response).to render_template 'edit'
end
it 'should update' do
it 'updates' do
expect do
put :update, params: { id: group.id, group: { lastname: 'new name' } }
end.to change { group.reload.name }.to('new name')
@ -81,7 +81,7 @@ describe GroupsController, type: :controller do
expect(response).to redirect_to groups_path
end
it 'should destroy' do
it 'destroys' do
perform_enqueued_jobs do
delete :destroy, params: { id: group.id }
end
@ -95,7 +95,7 @@ describe GroupsController, type: :controller do
let(:user1) { create :user }
let(:user2) { create :user }
it 'should add users' do
it 'adds users' do
post :add_users, params: { id: group.id, user_ids: [user1.id, user2.id] }
expect(group.reload.users.count).to eq 2
end
@ -106,20 +106,37 @@ describe GroupsController, type: :controller do
let(:user2) { create :user }
let(:group_members) { [user1] }
it 'should add users' do
it 'adds users' do
post :add_users, params: { id: group.id, user_ids: [user2.id] }
expect(group.reload.users.count).to eq 2
end
end
context 'with a global role membership' do
render_views
let!(:member_group) do
create(:global_member,
principal: group,
roles: [create(:global_role)])
end
it 'displays edit memberships' do
get :edit, params: { id: group.id, tab: 'memberships' }
expect(response).to be_successful
expect(response).to render_template 'edit'
end
end
context 'with project and role' do
let(:project) { create :project }
let(:role1) { create :role }
let(:role2) { create :role }
it 'should create membership' do
it 'creates membership' do
post :create_memberships,
params: { id: group.id, new_membership: { project_id: project.id, role_ids: [role1.id, role2.id] } }
params: { id: group.id, membership: { project_id: project.id, role_ids: [role1.id, role2.id] } }
expect(group.reload.members.count).to eq 1
expect(group.members.first.roles.count).to eq 2
@ -133,7 +150,7 @@ describe GroupsController, type: :controller do
roles: [role1])
end
it 'should edit a membership' do
it 'edits a membership' do
expect(group.members.count).to eq 1
expect(group.members.first.roles.count).to eq 1
@ -161,34 +178,34 @@ describe GroupsController, type: :controller do
let(:user) { create :user }
let(:current_user) { user }
it 'should forbid index' do
it 'forbids index' do
get :index
expect(response).not_to be_successful
expect(response.status).to eq 403
end
it 'should show' do
it 'shows' do
get :show, params: { id: group.id }
expect(response).to be_successful
expect(response).to render_template 'show'
end
it 'should forbid new' do
it 'forbids new' do
get :new
expect(response).not_to be_successful
expect(response.status).to eq 403
end
it 'should forbid create' do
it 'forbids create' do
expect do
post :create, params: { group: { lastname: 'New group' } }
end.not_to change { Group.count }
end.not_to(change(Group, :count))
expect(response).not_to be_successful
expect(response.status).to eq 403
end
it 'should forbid edit' do
it 'forbids edit' do
get :edit, params: { id: group.id }
expect(response).not_to be_successful

@ -101,7 +101,12 @@ describe MemberMailer, type: :mailer do
shared_examples_for 'has the expected body' do
let(:body) { subject.body.parts.detect { |part| part['Content-Type'].value == 'text/html' }.body.to_s }
let(:i18n_params) do
{
project: project ? link_to_project(project, only_path: false) : nil,
user: link_to_user(current_user, only_path: false)
}.compact
end
it 'highlights the roles received' do
expected = <<~MSG
@ -116,17 +121,24 @@ describe MemberMailer, type: :mailer do
.at_path('body/table/tr/td/ul')
end
context 'when current user and principal have different locales' do
let(:principal) { build_stubbed(:user, language: 'fr') }
let(:current_user) { build_stubbed(:user, language: 'de') }
it 'is in the locale of the recipient' do
OpenProject::LocaleHelper.with_locale_for(principal) do
i18n_params
end
expect(body).to include(I18n.t(:"#{expected_header}.without_message", locale: :fr, **i18n_params))
end
end
context 'with a custom message' do
let(:message) { "Some **styled** message" }
it 'has the expected header' do
params = {
project: project ? link_to_project(project, only_path: false) : nil,
user: link_to_user(current_user, only_path: false)
}.compact
expect(body)
.to include(I18n.t(:"#{expected_header}.with_message", **params))
.to include(I18n.t(:"#{expected_header}.with_message", **i18n_params))
end
it 'includes the custom message' do
@ -136,21 +148,15 @@ describe MemberMailer, type: :mailer do
end
context 'without a custom message' do
it 'has the expected header' do
params = {
project: project ? link_to_project(project, only_path: false) : nil,
user: link_to_user(current_user, only_path: false)
}.compact
expect(body)
.to include(I18n.t(:"#{expected_header}.without_message", **params))
.to include(I18n.t(:"#{expected_header}.without_message", **i18n_params))
end
end
end
describe '#added_project' do
subject { MemberMailer.added_project(current_user, member, message) }
subject { described_class.added_project(current_user, member, message) }
it_behaves_like "sends a mail to the member's principal"
it_behaves_like 'has a subject', :'mail_member_added_project.subject'
@ -165,7 +171,7 @@ describe MemberMailer, type: :mailer do
end
describe '#updated_project' do
subject { MemberMailer.updated_project(current_user, member, message) }
subject { described_class.updated_project(current_user, member, message) }
it_behaves_like "sends a mail to the member's principal"
it_behaves_like 'has a subject', :'mail_member_updated_project.subject'
@ -182,7 +188,7 @@ describe MemberMailer, type: :mailer do
describe '#updated_global' do
let(:project) { nil }
subject { MemberMailer.updated_global(current_user, member, message) }
subject { described_class.updated_global(current_user, member, message) }
it_behaves_like "sends a mail to the member's principal"
it_behaves_like 'has a subject', :'mail_member_updated_global.subject'

@ -62,4 +62,21 @@ describe Member, type: :model do
end
end
end
describe '#deletable_role?' do
it 'returns true if not inherited from a group' do
expect(member.deletable_role?(role)).to eq(true)
end
it 'returns false if role is inherited' do
member
group = create(:group, members: [user])
create(:member, project: project, principal: group, roles: [role])
::Groups::AddUsersService
.new(group, current_user: User.system, contract_class: EmptyContract)
.call(ids: [user.id])
expect(user.reload.memberships.map { _1.deletable_role?(role) }).to match_array([true, false])
end
end
end

@ -0,0 +1,207 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# 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 COPYRIGHT and LICENSE files for more details.
#++
require 'spec_helper'
# rubocop:disable RSpec/MultipleMemoizedHelpers
describe Members::RolesDiff, type: :model do
let(:project) { build_stubbed(:project) }
let(:group) { build_stubbed(:group) }
let(:user) { build_stubbed(:user) }
let(:role) { build_stubbed(:role) }
let(:role_other) { build_stubbed(:role) }
let(:group_member_role) { build_stubbed(:member_role, role: role) }
let(:group_member_role_other) { build_stubbed(:member_role, role: role_other) }
let(:group_member_roles) { raise NotImplementedError('please set group_member_roles') }
let(:group_member) do
build_stubbed(:member, principal: group, project: project, member_roles: group_member_roles)
end
let(:user_member_role) do
build_stubbed(:member_role, role: role)
end
let(:user_member_role_inherited) do
build_stubbed(:member_role, role: role, inherited_from: group_member_role.id)
end
let(:user_member_role_other) do
build_stubbed(:member_role, role: role_other)
end
let(:user_member_role_other_inherited) do
build_stubbed(:member_role, role: role_other, inherited_from: group_member_role_other.id)
end
let(:user_member_roles) { raise NotImplementedError('please set user_member_roles') }
let(:user_member) do
build_stubbed(:member, principal: user, project: project, member_roles: user_member_roles)
end
subject(:difference) do
described_class.new(user_member, group_member)
end
shared_examples 'roles created' do
it 'results in roles created' do
expect(difference.result).to eq(:roles_created)
end
end
shared_examples 'roles updated' do
it 'results in roles updated' do
expect(difference.result).to eq(:roles_updated)
end
end
shared_examples 'roles unchanged' do
it 'results in roles unchanged' do
expect(difference.result).to eq(:roles_unchanged)
end
end
context 'when group has added all its roles to a user' do
let(:group_member_roles) { [group_member_role, group_member_role_other] }
let(:user_member_roles) do
[
user_member_role_inherited,
user_member_role_other_inherited
]
end
include_examples 'roles created'
end
context 'when group has added all its roles to a user who already had some preexisting other roles' do
let(:group_member_roles) { [group_member_role] }
let(:user_member_roles) do
[
user_member_role_other,
user_member_role_inherited
]
end
include_examples 'roles updated'
end
context 'when group has added a new role and an existing role to a user' do
let(:group_member_roles) { [group_member_role, group_member_role_other] }
let(:user_member_roles) do
[
user_member_role,
user_member_role_inherited,
user_member_role_other_inherited
]
end
include_examples 'roles updated'
end
context 'when group has added already existing roles to a user' do
let(:group_member_roles) { [group_member_role, group_member_role_other] }
let(:user_member_roles) do
[
user_member_role,
user_member_role_other,
user_member_role_inherited,
user_member_role_other_inherited
]
end
include_examples 'roles unchanged'
end
context 'when group did not add any roles' do
let(:group_member_roles) { [group_member_role, group_member_role_other] }
let(:user_member_roles) do
[
user_member_role,
user_member_role_other
]
end
include_examples 'roles unchanged'
end
context 'when the projects are different between members' do
let(:group_member) do
build_stubbed(
:member,
principal: group,
project: create(:project)
)
end
let(:user_member) do
build_stubbed(
:member,
principal: user,
project: create(:project)
)
end
it 'raises ArgumentError' do
expect { difference.result }.to raise_error(ArgumentError)
end
end
context 'with another group defined' do
let(:other_group_member_role) { build_stubbed(:member_role, role: role) }
let(:other_group_member_role_other) { build_stubbed(:member_role, role: role_other) }
let(:user_member_role_inherited_from_other_group) do
build_stubbed(:member_role, role: role, inherited_from: other_group_member_role.id)
end
let(:user_member_role_other_inherited_from_other_group) do
build_stubbed(:member_role, role: role_other, inherited_from: other_group_member_role_other.id)
end
context 'when group has added to a user a new role and a role that already existed from another group membership' do
let(:group_member_roles) { [group_member_role, group_member_role_other] }
let(:user_member_roles) do
[
user_member_role_inherited_from_other_group,
user_member_role_inherited,
user_member_role_other_inherited
]
end
include_examples 'roles updated'
end
context 'when group has added to a user some roles that already existed from another group membership' do
let(:group_member_roles) { [group_member_role, group_member_role_other] }
let(:user_member_roles) do
[
user_member_role_inherited_from_other_group,
user_member_role_other_inherited_from_other_group,
user_member_role_inherited,
user_member_role_other_inherited
]
end
include_examples 'roles unchanged'
end
end
end
# rubocop:enable RSpec/MultipleMemoizedHelpers

@ -57,6 +57,14 @@ describe Principals::Scopes::Visible, type: :model do
end
end
context 'when user has no manage_members permission, but has manage_user global permission' do
current_user { create :user, global_permissions: %i[manage_user] }
it 'sees all users' do
expect(subject).to match_array [current_user, other_project_user, global_user]
end
end
context 'when user has no permission' do
current_user { create :user }

@ -45,7 +45,7 @@ describe Queries::Members::Filters::PrincipalFilter, type: :model do
.and_return(principal_scope)
allow(principal_scope)
.to receive(:in_visible_project_or_me)
.to receive(:visible)
.and_return([user, group, current_user])
end

@ -87,6 +87,8 @@ describe 'API v3 memberships resource', type: :request, content_type: :json do
describe 'GET api/v3/memberships' do
let(:members) { [own_member, other_member, invisible_member, global_member] }
let(:filters) { nil }
let(:path) { api_v3_paths.path_for(:memberships, filters: filters, sort_by: [%i(id asc)]) }
before do
members
@ -96,9 +98,6 @@ describe 'API v3 memberships resource', type: :request, content_type: :json do
get path
end
let(:filters) { nil }
let(:path) { api_v3_paths.path_for(:memberships, filters: filters, sort_by: [%i(id asc)]) }
context 'without params' do
it 'responds 200 OK' do
expect(subject.status).to eq(200)
@ -239,7 +238,7 @@ describe 'API v3 memberships resource', type: :request, content_type: :json do
end
end
context 'filtering by user name' do
context 'when filtering by user name' do
let(:filters) do
[{ 'any_name_attribute' => {
'operator' => '~',
@ -258,7 +257,7 @@ describe 'API v3 memberships resource', type: :request, content_type: :json do
end
end
context 'filtering by project' do
context 'when filtering by project' do
let(:members) { [own_member, other_member, invisible_member, own_other_member] }
let(:own_other_member) do
@ -288,7 +287,7 @@ describe 'API v3 memberships resource', type: :request, content_type: :json do
end
end
context 'filtering by principal' do
context 'when filtering by principal' do
let(:group) { create(:group) }
let(:group_member) do
create(:member,
@ -318,6 +317,24 @@ describe 'API v3 memberships resource', type: :request, content_type: :json do
.to be_json_eql(group_member.id.to_json)
.at_path('_embedded/elements/1/id')
end
context 'when principal is a group without any memberships' do
let(:members) { [own_member, other_member, invisible_member] }
let(:filters) do
[{ 'principal' => {
'operator' => '=',
'values' => [group.id.to_s]
} }]
end
it 'returns empty members' do
expect(subject.status).to eq(200)
expect(subject.body)
.to be_json_eql([])
.at_path('_embedded/elements')
end
end
end
context 'with the outdated created_on sort by (renamed to created_at)' do
@ -363,6 +380,7 @@ describe 'API v3 memberships resource', type: :request, content_type: :json do
context 'without permissions' do
let(:permissions) { [] }
it 'is empty' do
expect(subject.body)
.to be_json_eql('0')
@ -416,7 +434,7 @@ describe 'API v3 memberships resource', type: :request, content_type: :json do
end
it 'creates the member' do
expect(Member.find_by(user_id: principal.id, project: project))
expect(Member.find_by(principal: principal, project: project))
.to be_present
end
@ -551,6 +569,33 @@ describe 'API v3 memberships resource', type: :request, content_type: :json do
.to be_empty
end
end
context 'when creating global role permission as admin' do
let(:current_user) { admin }
let(:project) { nil }
let(:expected_role) { global_role }
let(:body) do
{
_links: {
principal: {
href: principal_path
},
roles: [
{
href: api_v3_paths.role(global_role.id)
}
]
},
_meta: {
notificationMessage: {
raw: custom_message
}
}
}.to_json
end
it_behaves_like 'successful member creation'
end
end
context 'for a placeholder user' do
@ -738,7 +783,7 @@ describe 'API v3 memberships resource', type: :request, content_type: :json do
it 'returns 200 OK' do
expect(subject.status)
.to eql(200)
.to be(200)
end
it 'returns the member' do
@ -758,7 +803,7 @@ describe 'API v3 memberships resource', type: :request, content_type: :json do
it 'returns 404 NOT FOUND' do
expect(subject.status)
.to eql(404)
.to be(404)
end
end
@ -767,7 +812,7 @@ describe 'API v3 memberships resource', type: :request, content_type: :json do
it 'returns 404 NOT FOUND' do
expect(subject.status)
.to eql(404)
.to be(404)
end
end
end
@ -869,13 +914,19 @@ describe 'API v3 memberships resource', type: :request, content_type: :json do
end
context 'with a group' do
# first user has no direct roles
# second user has direct role `another_role`
# both users belong to a group which has `other_role`, so this role is inherited by users
# when updating `group` role from `other_role` to `another_role`
# expecting to have first user role changed from `other_role` to `another_role`
# and second user role extended from `[other_role]` to `[other_role, another_role]` because has direct role
let(:group) do
create(:group, member_in_project: project, member_through_role: other_role, members: users)
end
let(:principal) { group }
let(:users) { [create(:user), create(:user)] }
let(:other_member) do
Member.find_by(principal: group).tap do |m|
Member.find_by(principal: group).tap do
# Behaves as if the user had that role before the role's membership was created.
# Because the user had the role independent of the group, it is not to be removed.
user_member = Member.find_by(principal: users.first)
@ -967,6 +1018,45 @@ describe 'API v3 memberships resource', type: :request, content_type: :json do
.to be_empty
end
end
context 'when updating global role permission as admin' do
let(:group) do
create(:group, global_role: other_role, members: users)
end
let(:current_user) { admin }
let(:project) { nil }
let(:other_role) { create(:global_role) }
let(:another_role) { create(:global_role) }
it 'responds with 200' do
expect(last_response.status).to eq(200)
end
it 'updates the member and all inherited members but does not update memberships users have already had' do
# other member is the group member
expect(other_member.reload.roles)
.to match_array [another_role]
expect(other_member.updated_at > other_member_updated_at)
.to be_truthy
last_user_member = Member.find_by(principal: users.last)
expect(last_user_member.roles)
.to match_array [another_role]
expect(last_user_member.updated_at > last_user_member_updated_at)
.to be_truthy
first_user_member = Member.find_by(principal: users.first)
expect(first_user_member.roles.uniq)
.to match_array [other_role, another_role]
expect(first_user_member.updated_at)
.to eql first_user_member_updated_at
end
end
end
context 'if attempting to empty the roles' do
@ -980,7 +1070,7 @@ describe 'API v3 memberships resource', type: :request, content_type: :json do
it 'returns 422' do
expect(last_response.status)
.to eql(422)
.to be(422)
expect(last_response.body)
.to be_json_eql("Roles need to be assigned.".to_json)
@ -1004,7 +1094,7 @@ describe 'API v3 memberships resource', type: :request, content_type: :json do
it 'returns 422' do
expect(last_response.status)
.to eql(422)
.to be(422)
expect(last_response.body)
.to be_json_eql("Roles has an unassignable role.".to_json)

@ -76,7 +76,8 @@ describe Groups::AddUsersService, 'integration', type: :model do
expect(Notifications::GroupMemberAlteredJob)
.to have_received(:perform_later)
.with(a_collection_containing_exactly(*ids),
.with(current_user,
a_collection_containing_exactly(*ids),
message,
true)
end
@ -158,8 +159,6 @@ describe Groups::AddUsersService, 'integration', type: :model do
.to match_array([user1, user2])
expect(user1.memberships.where(project_id: project).map(&:roles).flatten)
.to match_array(roles)
expect(user1.memberships.where(project_id: project).map(&:roles).flatten)
.to match_array(roles)
expect(user2.memberships.where(project_id: project).count).to eq 1
expect(user2.memberships.map(&:roles).flatten).to match_array roles
end
@ -210,6 +209,50 @@ describe Groups::AddUsersService, 'integration', type: :model do
let(:user) { user_ids }
end
end
context 'with global role' do
let(:role) { create :global_role }
let!(:group) do
create :group,
global_role: role,
global_permission: :add_project
end
it 'adds the users to the group and their membership to the global role' do
expect(service_call).to be_success
expect(group.users).to match_array([user1, user2])
expect(user1.memberships.where(project_id: nil).count).to eq 1
expect(user1.memberships.flat_map(&:roles)).to match_array [role]
expect(user2.memberships.where(project_id: nil).count).to eq 1
expect(user2.memberships.flat_map(&:roles)).to match_array [role]
end
context 'when one user already has a global role that the group would add' do
let(:global_roles) { create_list(:global_role, 2) }
let!(:group) do
create :group do |g|
create(:member,
project: nil,
principal: g,
roles: global_roles)
end
end
let!(:user_membership) do
create(:member,
project: nil,
roles: [global_roles.first],
principal: user1)
end
it 'adds their membership to the global role' do
expect(service_call).to be_success
expect(user1.memberships.where(project_id: nil).flat_map(&:roles)).to match_array global_roles
expect(user2.memberships.flat_map(&:roles)).to match_array global_roles
end
end
end
end
context 'when not allowed' do

@ -30,14 +30,16 @@ require 'spec_helper'
describe Groups::CleanupInheritedRolesService, 'integration', type: :model do
subject(:service_call) do
member.destroy
members.destroy_all
instance.call(params)
end
let(:project) { create :project }
let(:role) { create :role }
let(:global_role) { create :global_role }
let(:current_user) { create :admin }
let(:roles) { [role] }
let(:global_roles) { [global_role] }
let(:params) { { message: message } }
let(:message) { "Some message" }
@ -48,6 +50,9 @@ describe Groups::CleanupInheritedRolesService, 'integration', type: :model do
project: project,
principal: group,
roles: roles)
create(:global_member,
principal: group,
roles: global_roles)
::Groups::AddUsersService
.new(group, current_user: User.system, contract_class: EmptyContract)
@ -55,7 +60,7 @@ describe Groups::CleanupInheritedRolesService, 'integration', type: :model do
end
end
let(:users) { create_list :user, 2 }
let(:member) { Member.find_by(principal: group) }
let(:members) { Member.where(principal: group) }
let(:instance) do
described_class.new(group, current_user: current_user)
@ -104,10 +109,12 @@ describe Groups::CleanupInheritedRolesService, 'integration', type: :model do
context 'when also having own roles' do
let(:another_role) { create(:role) }
let(:another_global_role) { create(:global_role) }
let!(:first_user_member) do
group
Member.find_by(principal: users.first).tap do |m|
m.roles << another_role
m.roles << another_global_role
end
end
@ -130,7 +137,7 @@ describe Groups::CleanupInheritedRolesService, 'integration', type: :model do
.not_to eql(Member.find_by(id: first_user_member.id).updated_at)
expect(first_user_member.reload.roles)
.to match_array([another_role])
.to match_array([another_role, another_global_role])
end
it 'sends a notification on the kept membership' do
@ -138,17 +145,19 @@ describe Groups::CleanupInheritedRolesService, 'integration', type: :model do
expect(Notifications::GroupMemberAlteredJob)
.to have_received(:perform_later)
.with([first_user_member.id],
.with(current_user,
[first_user_member.id],
message,
true)
end
end
context 'when the user has had the role added by the group before' do
context 'when the user has had the roles added by the group before' do
let(:another_role) { create(:role) }
let!(:first_user_member) do
Member.find_by(principal: users.first).tap do |m|
m.member_roles.create(role: role)
m.member_roles.create(role: global_role)
end
end
@ -171,7 +180,7 @@ describe Groups::CleanupInheritedRolesService, 'integration', type: :model do
.not_to eql(Member.find_by(id: first_user_member.id).updated_at)
expect(first_user_member.reload.roles)
.to match_array([role])
.to match_array([role, global_role])
end
it 'sends a notification on the kept membership' do
@ -179,7 +188,8 @@ describe Groups::CleanupInheritedRolesService, 'integration', type: :model do
expect(Notifications::GroupMemberAlteredJob)
.to have_received(:perform_later)
.with([first_user_member.id],
.with(current_user,
[first_user_member.id],
message,
true)
end
@ -191,7 +201,7 @@ describe Groups::CleanupInheritedRolesService, 'integration', type: :model do
.where(member_id: Member.where(principal: users.first))
.pluck(:id)
end
let(:params) { { member_role_ids: member_role_ids} }
let(:params) { { member_role_ids: member_role_ids } }
it 'is successful' do
expect(service_call)

@ -65,14 +65,14 @@ describe Groups::UpdateRolesService, 'integration', type: :model do
shared_examples_for 'keeps timestamp' do
it 'updated_at on member is unchanged' do
expect { service_call }
.not_to change { Member.find_by(principal: user).updated_at }
.not_to(change { Member.find_by(principal: user).updated_at })
end
end
shared_examples_for 'updates timestamp' do
it 'updated_at on member is changed' do
expect { service_call }
.to change { Member.find_by(principal: user).updated_at }
.to(change { Member.find_by(principal: user).updated_at })
end
end
@ -82,7 +82,8 @@ describe Groups::UpdateRolesService, 'integration', type: :model do
expect(Notifications::GroupMemberAlteredJob)
.to have_received(:perform_later)
.with(a_collection_containing_exactly(*Member.where(principal: user).pluck(:id)),
.with(current_user,
a_collection_containing_exactly(*Member.where(principal: user).pluck(:id)),
message,
true)
end
@ -114,6 +115,74 @@ describe Groups::UpdateRolesService, 'integration', type: :model do
end
end
context 'with global membership' do
let(:role) { create :global_role }
let!(:group) do
create(:group,
members: users).tap do |group|
create(:global_member,
principal: group,
roles: roles)
::Groups::AddUsersService
.new(group, current_user: User.system, contract_class: EmptyContract)
.call(ids: users.map(&:id))
end
end
context 'when adding a global role' do
let(:added_role) { create(:global_role) }
before do
member.roles << added_role
end
it 'is successful' do
expect(service_call)
.to be_success
end
it 'adds the roles to all inherited memberships' do
service_call
Member.where(principal: users).each do |member|
expect(member.roles)
.to match_array([role, added_role])
end
end
it_behaves_like 'sends notification' do
let(:user) { users }
end
end
context 'when removing a global role' do
let(:roles) { [role, create(:global_role)] }
before do
member.roles = [role]
end
it 'is successful' do
expect(service_call)
.to be_success
end
it 'removes the roles from all inherited memberships' do
service_call
Member.where(principal: users).each do |member|
expect(member.roles)
.to match_array([role])
end
end
it_behaves_like 'sends notification' do
let(:user) { users }
end
end
end
context 'when adding a role but with one user having had the role before (no inherited from)' do
let(:added_role) { create(:role) }

@ -115,7 +115,7 @@ module Pages
end
def select_project!(project_name)
select(project_name, from: 'new_membership_project_id')
select(project_name, from: 'membership_project_id')
end
def add_user!(user_name)

@ -82,7 +82,8 @@ describe Mails::MemberCreatedJob, type: :model do
it_behaves_like 'sends no mail'
end
context 'with the user having had a membership before the group`s membership was added but now has additional roles' do
context 'with the user having had a membership before the group`s membership ' +
'was added but now has additional roles' do
let(:other_role) { build_stubbed(:role) }
let(:group_user_member_roles) do
[build_stubbed(:member_role,
@ -96,11 +97,84 @@ describe Mails::MemberCreatedJob, type: :model do
it 'sends mail' do
run_job
expect(MemberMailer)
.not_to have_received(:added_project)
expect(MemberMailer)
.to have_received(:updated_project)
.with(current_user, group_user_member, message)
end
end
end
context 'with a group global membership' do
let(:project) { nil }
let(:member) do
build_stubbed(:member,
project: project,
principal: group,
member_roles: group_member_roles)
end
before do
group_user_member
end
context 'with the user not having had a membership before the group`s membership was added' do
let(:group_user_member_roles) do
[build_stubbed(:member_role,
role: role,
inherited_from: group_member_roles.first.id)]
end
it 'sends mail' do
run_job
expect(MemberMailer)
.to have_received(:updated_global)
.with(current_user, group_user_member, message)
end
end
context 'with the user having had a membership with the same roles before the group`s membership was added' do
let(:group_user_member_roles) do
[build_stubbed(:member_role,
role: role,
inherited_from: nil)]
end
it_behaves_like 'sends no mail'
end
context 'with the user having had a membership with the same roles
from another group before the group`s membership was added' do
let(:group_user_member_roles) do
[build_stubbed(:member_role,
role: role,
inherited_from: group_member_roles.first.id + 5)]
end
it_behaves_like 'sends no mail'
end
context 'with the user having had a membership before the group`s membership was added but now has additional roles' do
let(:other_role) { build_stubbed(:role) }
let(:group_user_member_roles) do
[build_stubbed(:member_role,
role: role,
inherited_from: group_member_roles.first.id),
build_stubbed(:member_role,
role: other_role,
inherited_from: nil)]
end
it 'sends mail' do
run_job
expect(MemberMailer)
.to have_received(:updated_global)
.with(current_user, group_user_member, message)
end
end
end
end
end

@ -31,7 +31,7 @@ require_relative 'shared/member_job'
describe Mails::MemberUpdatedJob, type: :model do
include_examples 'member job' do
let(:user_project_mail_method) { :updated_project}
let(:user_project_mail_method) { :updated_project }
context 'with a group membership' do
let(:member) do
@ -100,5 +100,74 @@ describe Mails::MemberUpdatedJob, type: :model do
it_behaves_like 'updated mail'
end
end
context 'with a group global membership' do
let(:project) { nil }
let(:member) do
build_stubbed(:member,
project: project,
principal: group,
member_roles: group_member_roles)
end
shared_examples 'updated mail' do
it 'sends mail' do
run_job
expect(MemberMailer)
.to have_received(:updated_global)
.with(current_user, group_user_member, message)
end
end
before do
group_user_member
end
context 'with the user not having had a membership before the group`s membership was added' do
let(:group_user_member_roles) do
[build_stubbed(:member_role,
role: role,
inherited_from: group_member_roles.first.id)]
end
it_behaves_like 'updated mail'
end
context 'with the user having had a membership with the same roles before the group`s membership was added' do
let(:group_user_member_roles) do
[build_stubbed(:member_role,
role: role,
inherited_from: nil)]
end
it_behaves_like 'sends no mail'
end
context 'with the user having had a membership with the same roles
from another group before the group`s membership was added' do
let(:group_user_member_roles) do
[build_stubbed(:member_role,
role: role,
inherited_from: group_member_roles.first.id + 5)]
end
it_behaves_like 'sends no mail'
end
context 'with the user having had a membership before the group`s membership was added but now has additional roles' do
let(:other_role) { build_stubbed(:role) }
let(:group_user_member_roles) do
[build_stubbed(:member_role,
role: role,
inherited_from: group_member_roles.first.id),
build_stubbed(:member_role,
role: other_role,
inherited_from: nil)]
end
it_behaves_like 'updated mail'
end
end
end
end

@ -32,9 +32,10 @@ require 'spec_helper'
describe Notifications::GroupMemberAlteredJob, type: :model do
subject(:service_call) do
described_class.new.perform(members_ids, message, send_notification)
described_class.new.perform(current_user, members_ids, message, send_notification)
end
let(:current_user) { build_stubbed(:user) }
let(:time) { Time.now }
let(:member1) do
build_stubbed(:member, updated_at: time, created_at: time)
@ -72,4 +73,15 @@ describe Notifications::GroupMemberAlteredJob, type: :model do
.to have_received(:send)
.with(OpenProject::Events::MEMBER_UPDATED, member: member2, message: message, send_notifications: send_notification)
end
it 'propagates the given current user when sending notifications' do
captured_current_user = nil
allow(OpenProject::Notifications)
.to receive(:send) do |_args|
captured_current_user = User.current
end
service_call
expect(captured_current_user).to be(current_user)
end
end

Loading…
Cancel
Save