Compare commits

...

13 Commits

Author SHA1 Message Date
Benjamin Bädorf f2fe7285d6
Adding reactive forms 4 years ago
Benjamin Bädorf 7cbfdc6afe
Started adding option-list component 4 years ago
Benjamin Bädorf cbc8e97fdc
Fixed build 4 years ago
Benjamin Bädorf 52cbb1f878
Figured out basic modal setup 4 years ago
Benjamin Bädorf 691f55b6b2
Changed modal to module 4 years ago
Benjamin Bädorf 9baacafbc6
Added invite user modal title translation 4 years ago
Benjamin Bädorf ee9cc75818
Builds 4 years ago
Benjamin Bädorf 4e848c9f9e
Small use rmodal changes 4 years ago
Benjamin Bädorf 802447e9ba
Small change to invite user modal 4 years ago
Benjamin Bädorf 3155074233
Add initial invite user modal 4 years ago
Wieland Lindenthal 9a6b77eb69
Differentiate Placeholder from User in HAL links 4 years ago
Wieland Lindenthal 7cf751d7ab
Separate admin menu item for placeholder users 4 years ago
Wieland Lindenthal 52093838e4
WIP: First commit for placeholder users 4 years ago
  1. 69
      app/cells/placeholder_user_filter_cell.rb
  2. 11
      app/cells/placeholder_users/placeholder_user_filter_cell.rb
  3. 28
      app/cells/placeholder_users/row_cell.rb
  4. 28
      app/cells/placeholder_users/table_cell.rb
  5. 85
      app/cells/views/placeholder_user_filter/show.erb
  6. 92
      app/controllers/placeholder_users/memberships_controller.rb
  7. 160
      app/controllers/placeholder_users_controller.rb
  8. 30
      app/models/anonymous_user.rb
  9. 4
      app/models/group.rb
  10. 11
      app/models/permitted_params.rb
  11. 46
      app/models/placeholder_user.rb
  12. 7
      app/models/principal.rb
  13. 5
      app/models/project.rb
  14. 39
      app/models/queries/placeholder_users.rb
  15. 37
      app/models/queries/placeholder_users/placeholder_user_query.rb
  16. 8
      app/models/user.rb
  17. 45
      app/views/placeholder_users/_form.html.erb
  18. 40
      app/views/placeholder_users/_general.html.erb
  19. 49
      app/views/placeholder_users/_groups.html.erb
  20. 148
      app/views/placeholder_users/_memberships.html.erb
  21. 36
      app/views/placeholder_users/edit.html.erb
  22. 46
      app/views/placeholder_users/index.html.erb
  23. 43
      app/views/placeholder_users/new.html.erb
  24. 2
      app/views/users/_groups.html.erb
  25. 6
      app/views/users/_toolbar.html.erb
  26. 4
      app/views/users/index.html.erb
  27. 5
      config/initializers/menus.rb
  28. 12
      config/locales/en.yml
  29. 3
      config/locales/js-en.yml
  30. 12
      config/routes.rb
  31. 38
      docker-compose.yml
  32. 4
      frontend/src/app/angular4-modules.ts
  33. 18
      frontend/src/app/components/user/user-avatar/user-avatar-renderer.service.ts
  34. 2
      frontend/src/app/components/user/user-avatar/user-avatar.component.ts
  35. 1
      frontend/src/app/modules/boards/openproject-boards.module.ts
  36. 3
      frontend/src/app/modules/common/openproject-common.module.ts
  37. 22
      frontend/src/app/modules/common/option-list/option-list.component.html
  38. 17
      frontend/src/app/modules/common/option-list/option-list.component.sass
  39. 48
      frontend/src/app/modules/common/option-list/option-list.component.ts
  40. 8
      frontend/src/app/modules/fields/edit/field-types/select-edit-field.component.ts
  41. 0
      frontend/src/app/modules/invite-user-modal/group.component.html
  42. 0
      frontend/src/app/modules/invite-user-modal/group.component.sass
  43. 15
      frontend/src/app/modules/invite-user-modal/group.component.ts
  44. 34
      frontend/src/app/modules/invite-user-modal/invite-user-modal.module.ts
  45. 17
      frontend/src/app/modules/invite-user-modal/invite-user.component.html
  46. 0
      frontend/src/app/modules/invite-user-modal/invite-user.component.sass
  47. 60
      frontend/src/app/modules/invite-user-modal/invite-user.component.ts
  48. 0
      frontend/src/app/modules/invite-user-modal/message.component.html
  49. 0
      frontend/src/app/modules/invite-user-modal/message.component.sass
  50. 15
      frontend/src/app/modules/invite-user-modal/message.component.ts
  51. 0
      frontend/src/app/modules/invite-user-modal/placeholder.component.html
  52. 0
      frontend/src/app/modules/invite-user-modal/placeholder.component.sass
  53. 15
      frontend/src/app/modules/invite-user-modal/placeholder.component.ts
  54. 33
      frontend/src/app/modules/invite-user-modal/project-selection.component.html
  55. 0
      frontend/src/app/modules/invite-user-modal/project-selection.component.sass
  56. 75
      frontend/src/app/modules/invite-user-modal/project-selection.component.ts
  57. 0
      frontend/src/app/modules/invite-user-modal/role.component.html
  58. 0
      frontend/src/app/modules/invite-user-modal/role.component.sass
  59. 15
      frontend/src/app/modules/invite-user-modal/role.component.ts
  60. 0
      frontend/src/app/modules/invite-user-modal/success.component.html
  61. 0
      frontend/src/app/modules/invite-user-modal/success.component.sass
  62. 15
      frontend/src/app/modules/invite-user-modal/success.component.ts
  63. 0
      frontend/src/app/modules/invite-user-modal/summary.component.html
  64. 0
      frontend/src/app/modules/invite-user-modal/summary.component.sass
  65. 15
      frontend/src/app/modules/invite-user-modal/summary.component.ts
  66. 0
      frontend/src/app/modules/invite-user-modal/user.component.html
  67. 0
      frontend/src/app/modules/invite-user-modal/user.component.sass
  68. 15
      frontend/src/app/modules/invite-user-modal/user.component.ts
  69. 3
      frontend/src/app/modules/work_packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.html
  70. 12
      frontend/src/app/modules/work_packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.ts
  71. 8
      frontend/src/global_styles/content/_user.sass
  72. 4
      frontend/src/global_styles/openproject.sass
  73. 144
      lib/api/v3/placeholder_users/placeholder_user_representer.rb
  74. 7
      lib/api/v3/principals/associated_subclass_lambda.rb
  75. 4
      lib/api/v3/principals/group_or_user_or_placeholder_user_elements.rb
  76. 2
      lib/api/v3/users/paginated_user_collection_representer.rb
  77. 2
      lib/api/v3/users/user_collection_representer.rb
  78. 4
      lib/api/v3/utilities/path_helper.rb
  79. 2
      lib/api/v3/work_packages/principal_setter.rb
  80. 27
      lib/open_project/ui/extensible_tabs.rb

@ -0,0 +1,69 @@
class PlaceholderUserFilterCell < RailsCell
include UsersHelper
include ActionView::Helpers::FormOptionsHelper
options :groups, :roles, :clear_url, :project
class << self
def filter(params)
q = base_query.new
filter_project q, params[:project_id]
filter_name q, params[:name]
filter_group q, params[:group_id]
filter_role q, params[:role_id]
q.results
end
def filtered?(params)
%i(name group_id role_id).any? { |name| params[name].present? }
end
def filter_name(query, name)
if name.present?
query.where(:any_name_attribute, '~', name)
end
end
def filter_group(query, group_id)
if group_id.present?
query.where(:group, '=', group_id)
end
end
def filter_role(query, role_id)
if role_id.present?
query.where(:role_id, '=', role_id)
end
end
def filter_project(query, project_id)
if project_id.present?
query.where(:project_id, '=', project_id)
end
end
def base_query
Queries::PlaceholderUsers::PlaceholderUserQuery
end
end
# INSTANCE METHODS:
def filter_path
placeholder_users_path
end
def initially_visible?
true
end
def has_close_icon?
false
end
def params
model
end
end

@ -0,0 +1,11 @@
module PlaceholderUsers
class PlaceholderUserFilterCell < ::PlaceholderUserFilterCell
def filter_role(query, role_id)
super.uniq
end
def clear_url
placeholder_users_path
end
end
end

@ -0,0 +1,28 @@
module PlaceholderUsers
class RowCell < ::RowCell
include AvatarHelper
include UsersHelper
def placeholder_user
model
end
def lastname
link_to h(placeholder_user.name), edit_placeholder_user_path(placeholder_user)
end
def button_links
[delete_link].compact
end
def delete_link
return nil unless Users::DeleteService.deletion_allowed? placeholder_user, User.current
link_to '',
placeholder_user_path(placeholder_user),
data: { confirm: 'Are you sure?' },
class: 'icon icon-delete',
method: :delete
end
end
end

@ -0,0 +1,28 @@
module PlaceholderUsers
class TableCell < ::TableCell
options :current_user # adds this option to those of the base class
columns :lastname, :created_at
def initial_sort
[:id, :asc]
end
def headers
columns.map do |name|
[name.to_s, header_options(name)]
end
end
def header_options(name)
options = { caption: name == :lastname ? User.human_attribute_name(:name) : User.human_attribute_name(name) }
options[:default_order] = 'desc' if desc_by_default.include? name
options
end
def desc_by_default
[:created_at]
end
end
end

@ -0,0 +1,85 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2020 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 docs/COPYRIGHT.rdoc for more details.
++#%>
<%= form_tag(filter_path, method: :get) do %>
<% collapsed_class = initially_visible? ? '' : 'collapsed' %>
<fieldset class="simple-filters--container <%= collapsed_class %>">
<legend><%= t(:label_filter_plural) %></legend>
<% if has_close_icon? %>
<a title="<%= t('js.close_form_title') %>"
class="toggle-member-filter-link simple-filters--close icon-context icon-close">
</a>
<% end %>
<ul class="simple-filters--filters">
<% if groups.present? %>
<li class="simple-filters--filter">
<label class='simple-filters--filter-name' for='group_id'><%= Group.model_name.human %>:</label>
<%= collection_select :group,
:id,
groups,
:id,
:name,
{ include_blank: true,
selected: params[:group_id].to_i },
{ name: "group_id",
class: 'simple-filters--filter-value' } %>
</li>
<% end %>
<% if roles.present? %>
<li class="simple-filters--filter">
<label class='simple-filters--filter-name' for='role_id'><%= Role.model_name.human %>:</label>
<%=
collection_select(
:role,
:id,
roles,
:id,
:name,
{
include_blank: true,
selected: params[:role_id].to_i
},
{
name: "role_id",
class: 'simple-filters--filter-value'
})
%>
</li>
<% end %>
<li class="simple-filters--filter">
<label class='simple-filters--filter-name' for='name'><%= User.human_attribute_name :name %>:</label>
<%= text_field_tag 'name', params[:name], class: 'simple-filters--filter-value' %>
</li>
<li class="simple-filters--controls">
<%= submit_tag t(:button_apply), class: 'button -highlight -small', name: nil %>
<%= link_to t(:button_clear), clear_url, class: 'button -small -with-icon icon-undo' %>
</li>
</ul>
</fieldset>
<% end %>

@ -0,0 +1,92 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-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 docs/COPYRIGHT.rdoc for more details.
#++
class PlaceholderUsers::MembershipsController < ApplicationController
layout 'admin'
before_action :require_admin
before_action :find_placeholder_user
def update
update_or_create(request.patch?, :notice_successful_update)
end
def create
update_or_create(request.post?, :notice_successful_create)
end
def destroy
@membership = @placeholder_user.memberships.find(params[:id])
tab = redirected_to_tab(@membership)
if @membership.deletable? && request.delete?
@membership.destroy
@membership = nil
flash[:notice] = I18n.t(:notice_successful_delete)
end
redirect_to controller: '/placeholder_users', action: 'edit', id: @user, tab: tab
end
private
def update_or_create(save_record, message)
@membership = params[:id].present? ? Member.find(params[:id]) : Member.new(principal: @placeholder_user, project: nil)
result = ::Members::EditMembershipService
.new(@membership, save: save_record, current_user: current_user)
.call(attributes: permitted_params.membership)
if result.success?
flash[:notice] = I18n.t(message)
else
flash[:error] = result.errors.full_messages.join("\n")
end
redirect_to controller: '/placeholder_users',
action: 'edit',
id: @placeholder_user,
tab: redirected_to_tab(@membership)
end
def find_placeholder_user
@placeholder_user = PlaceholderUser.find(params[:placeholder_user_id])
rescue ActiveRecord::RecordNotFound
render_404
end
def redirected_to_tab(membership)
if membership.project
'memberships'
else
'global_roles'
end
end
end

@ -0,0 +1,160 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-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 docs/COPYRIGHT.rdoc for more details.
#++
class PlaceholderUsersController < ApplicationController
layout 'admin'
helper_method :gon
before_action :require_admin, except: [:show, :deletion_info, :destroy]
before_action :find_placeholder_user, only: [:show,
:edit,
:update,
:destroy,
:resend_invitation]
before_action :check_if_deletion_allowed, only: [:destroy]
def index
@groups = Group.all.sort
@placeholder_users = PlaceholderUsers::PlaceholderUserFilterCell.filter params
respond_to do |format|
format.html do
render layout: !request.xhr?
end
end
end
def show
# show projects based on current user visibility
@memberships = @user.memberships
.visible(current_user)
events = Activities::Fetcher.new(User.current, author: @user).events(nil, nil, limit: 10)
@events_by_day = events.group_by { |e| e.event_datetime.to_date }
if !User.current.admin? &&
(!(@user.active? ||
@user.registered?) ||
(@user != User.current && @memberships.empty? && events.empty?))
render_404
else
respond_to do |format|
format.html { render layout: 'no_menu' }
end
end
end
def new
@placeholder_user = PlaceholderUser.new
end
def create
@placeholder_user = PlaceholderUser.new
@placeholder_user.attributes = permitted_params.placeholder_user
if @placeholder_user.save
respond_to do |format|
format.html do
flash[:notice] = I18n.t(:notice_successful_create)
redirect_to(params[:continue] ? new_placeholder_user_path : edit_placeholder_user_path(@placeholder_user))
end
end
else
respond_to do |format|
format.html do
render action: :new
end
end
end
end
def edit
@membership ||= Member.new
end
def update
@placeholder_user.attributes = permitted_params.placeholder_user
if @placeholder_user.save
respond_to do |format|
format.html do
flash[:notice] = I18n.t(:notice_successful_update)
redirect_back(fallback_location: edit_placeholder_user_path(@placeholder_user))
end
end
else
@membership ||= Member.new
respond_to do |format|
format.html do
render action: :edit
end
end
end
end
def destroy
Users::DeleteService.new(@placeholder_user, User.current).call
flash[:notice] = I18n.t('account.deleted')
respond_to do |format|
format.html do
redirect_to placeholder_users_path
end
end
end
private
def find_placeholder_user
@placeholder_user = PlaceholderUser.find(params[:id])
rescue ActiveRecord::RecordNotFound
render_404
end
def check_if_deletion_allowed
render_404 unless Users::DeleteService.deletion_allowed? @placeholder_user, User.current
end
protected
def default_breadcrumb
if action_name == 'index'
t('label_placeholder_user_plural')
else
ActionController::Base.helpers.link_to(t('label_placeholder_user_plural'), placeholder_users_path)
end
end
def show_local_breadcrumb
current_user.admin?
end
end

@ -1,3 +1,33 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-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 docs/COPYRIGHT.rdoc for more details.
#++
class AnonymousUser < User
validate :validate_unique_anonymous_user, on: :create

@ -33,6 +33,10 @@ class Group < Principal
before_add: :fail_add,
after_remove: :user_removed
has_and_belongs_to_many :placeholder_users,
join_table: "#{table_name_prefix}group_users#{table_name_suffix}",
before_add: :fail_add,
after_remove: :user_removed
acts_as_customizable
before_destroy :remove_references_before_destroy

@ -188,6 +188,13 @@ class PermittedParams
permitted_params
end
def placeholder_user
permitted_params = params.require(:placeholder_user).permit(*self.class.permitted_attributes[:placeholder_user])
permitted_params = permitted_params.merge(custom_field_values(:placeholder_user))
permitted_params
end
def user_register_via_omniauth
permitted_params = params
.require(:user)
@ -534,6 +541,10 @@ class PermittedParams
:client_credentials_user_id,
scopes: []
],
placeholder_user: %i(
lastname
custom_fields
),
project_type: [
:name,
type_ids: []],

@ -0,0 +1,46 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-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 docs/COPYRIGHT.rdoc for more details.
#++
class PlaceholderUser < Principal
validates_presence_of(:lastname)
validates_uniqueness_of(:lastname)
acts_as_customizable
has_and_belongs_to_many :groups,
join_table: "#{table_name_prefix}group_users#{table_name_suffix}",
foreign_key: 'user_id',
after_add: ->(user, group) { group.user_added(user) },
after_remove: ->(user, group) { group.user_removed(user) }
def to_s
lastname
end
end

@ -73,6 +73,13 @@ class Principal < ApplicationRecord
}
scope :not_builtin, -> {
# TODO: Remove PlaceholderUser from this list. This is a temporary hack.
where.not(type: [SystemUser.name, AnonymousUser.name, DeletedUser.name, PlaceholderUser.name])
}
scope :not_builtin_but_with_placeholder_users, -> {
# TODO: This is temporary precaution scope to circumvent the hack in the :not_builtin scope. Needs to be
# removed.
where.not(type: [SystemUser.name, AnonymousUser.name, DeletedUser.name])
}

@ -101,6 +101,7 @@ class Project < ApplicationRecord
includes(:principal)
.references(:principals)
.where("#{Principal.table_name}.type='Group' OR " +
"(#{Principal.table_name}.type='PlaceholderUser' OR " +
"(#{Principal.table_name}.type='User' AND " +
"(#{Principal.table_name}.status=#{Principal::STATUSES[:active]} OR " +
"#{Principal.table_name}.status=#{Principal::STATUSES[:registered]} OR " +
@ -522,9 +523,9 @@ class Project < ApplicationRecord
def self.possible_principles_condition
condition = if Setting.work_package_group_assignment?
["(#{Principal.table_name}.type=? OR #{Principal.table_name}.type=?)", 'User', 'Group']
["(#{Principal.table_name}.type=? OR #{Principal.table_name}.type=? OR #{Principal.table_name}.type=?)", 'User', 'Group', 'PlaceholderUser']
else
["(#{Principal.table_name}.type=?)", 'User']
["(#{Principal.table_name}.type=? OR #{Principal.table_name}.type=?)", 'User', 'PlaceholderUser']
end
condition[0] += " AND (#{User.table_name}.status=? OR #{User.table_name}.status=?) AND roles.assignable = ?"

@ -0,0 +1,39 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-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 docs/COPYRIGHT.rdoc for more details.
#++
module Queries::PlaceholderUsers
Queries::Register.filter Queries::PlaceholderUsers::PlaceholderUserQuery, Queries::Users::Filters::NameFilter
Queries::Register.filter Queries::PlaceholderUsers::PlaceholderUserQuery, Queries::Users::Filters::AnyNameAttributeFilter
Queries::Register.filter Queries::PlaceholderUsers::PlaceholderUserQuery, Queries::Users::Filters::GroupFilter
Queries::Register.order Queries::PlaceholderUsers::PlaceholderUserQuery, Queries::Users::Orders::DefaultOrder
Queries::Register.order Queries::PlaceholderUsers::PlaceholderUserQuery, Queries::Users::Orders::NameOrder
Queries::Register.order Queries::PlaceholderUsers::PlaceholderUserQuery, Queries::Users::Orders::GroupOrder
end

@ -0,0 +1,37 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-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 docs/COPYRIGHT.rdoc for more details.
#++
class Queries::PlaceholderUsers::PlaceholderUserQuery < Queries::BaseQuery
def self.model
PlaceholderUser
end
def default_scope
PlaceholderUser.not_builtin_but_with_placeholder_users
end
end

@ -118,11 +118,15 @@ class User < Principal
attr_accessor :password, :password_confirmation
attr_accessor :last_before_login_on
validates_presence_of :login,
validates_presence_of(:login,
:firstname,
:lastname,
:mail,
unless: Proc.new { |user| user.is_a?(AnonymousUser) || user.is_a?(DeletedUser) || user.is_a?(SystemUser) }
unless: Proc.new do |user|
user.is_a?(AnonymousUser) ||
user.is_a?(DeletedUser) ||
user.is_a?(SystemUser) ||
user.is_a?(PlaceholderUser) end)
validates_uniqueness_of :login, if: Proc.new { |user| !user.login.blank? }, case_sensitive: false
validates_uniqueness_of :mail, allow_blank: true, case_sensitive: false

@ -0,0 +1,45 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2020 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-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 docs/COPYRIGHT.rdoc for more details.
++#%>
<%= error_messages_for 'placeholder_user' %>
<!--[form:user]-->
<section class="form--section">
<div class="form--field -required"><%= f.text_field :lastname, label: t('label_name'), required: true, container_class: '-middle' %></div>
<h3 class="form--section-title"><%= t(:label_custom_field_plural) %></h3>
<%= render partial: 'customizable/field',
collection: @placeholder_user.custom_field_values,
as: :value,
locals: { form: f } %>
<%= call_hook(:view_placeholder_users_form, placeholder_user: @placeholder_user, form: f) %>
</section>
<!--[eoform:user]-->

@ -0,0 +1,40 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2020 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-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 docs/COPYRIGHT.rdoc for more details.
++#%>
<%= labelled_tabular_form_for @user,
url: { controller: '/placeholder_users',
action: "update",
tab: nil },
html: { method: :put,
autocomplete: 'off' },
as: :placeholder_user do |f| %>
<%= render partial: 'form', locals: { f: f } %>
<%= styled_button_tag t(:button_save), class: '-highlight -with-icon icon-checkmark' %>
<% end %>

@ -0,0 +1,49 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2020 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-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 docs/COPYRIGHT.rdoc for more details.
++#%>
<% groups = @placeholder_user.groups.pluck(:id, :lastname) %>
<%= labelled_tabular_form_for(:placeholder_user, url: { action: 'update' }, html: { method: :put }) do %>
<section class="form--section">
<% if groups.empty? %>
<%= no_results_box action_url: groups_path,
display_action: true,
custom_title: t('placeholder_users.groups.no_results_title_text'),
custom_action_text: t(:label_manage_groups)
%>
<% else %>
<p><%= t('placeholder_users.groups.member_in_these_groups') %></p>
<% end %>
<% groups.each do |id, name| %>
<ul>
<li><%= link_to name, edit_group_path(id) %></li>
</ul>
<% end %>
</section>
<% end %>

@ -0,0 +1,148 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2020 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-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 docs/COPYRIGHT.rdoc for more details.
++#%>
<% roles = Role.givable %>
<% projects = Project.active.order(Arel.sql('lft')) %>
<div class="grid-block">
<div class="grid-content">
<% if @placeholder_user.memberships.any? %>
<div class="generic-table--container">
<div class="generic-table--results-container">
<table class="generic-table memberships">
<colgroup>
<col highlight-col>
<col highlight-col>
<%= call_hook(:view_placeholder_users_memberships_table_colgroup, placeholder_user: @placeholder_user )%>
<col>
</colgroup>
<thead>
<tr>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= Project.model_name.human %>
</span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= t(:label_role_plural) %>
</span>
</div>
</div>
</th>
<%= call_hook(:view_users_memberships_table_header, placeholder_user: @placeholder_user )%>
<th><div class="generic-table--empty-header"></div></th>
</tr>
</thead>
<tbody>
<% @placeholder_user.memberships.where.not(project: nil).each do |membership| %>
<% next if membership.new_record? %>
<tr id="member-<%= membership.id %>" class="member">
<td class="project">
<%= link_to_project membership.project %>
</td>
<td class="roles">
<span id="member-<%= membership.id %>-roles"
class="member-<%= membership.id %>--edit-toggle-item">
<%=h membership.roles.sort.collect(&:to_s).join(', ') %>
</span>
<%= labelled_tabular_form_for(:membership,
url: placeholder_user_membership_path(placeholder_user_id: @placeholder_user, id: membership),
html: { id: "member-#{membership.id}-roles-form",
class: "member-#{membership.id}--edit-toggle-item",
style: 'display:none;'},
method: :patch) do |f| %>
<div>
<% roles.each do |role| %>
<label class="form--label-with-check-box">
<%= f.collection_check_box :role_ids,
role.id,
membership.roles.include?(role),
role.name,
disabled: membership.member_roles.detect {|mr| mr.role_id == role.id && !mr.inherited_from.nil?},
no_label: true,
id: nil
%>
<%= role %>
</label>
<% end %>
</div>
<p><%= submit_tag t(:button_change), class: 'user-memberships--edit-submit-button button -highlight -small' %>
<%= link_to_function t(:button_cancel),
"jQuery('.member-#{membership.id}--edit-toggle-item').toggle();",
class: 'button -small' %></p>
<% end %>
</td>
<%= call_hook(:view_placheholder_users_memberships_table_row, placeholder_user: @placeholder_user, membership: membership, roles: roles, projects: projects )%>
<td class="buttons">
<%= link_to_function icon_wrapper('icon icon-edit', t(:button_edit)),
"jQuery('.member-#{membership.id}--edit-toggle-item').toggle();",
class: "member-#{membership.id}--edit-toggle-item user-memberships--edit-button",
title: t(:button_edit) %>
<%= link_to(icon_wrapper('icon icon-remove', t(:button_remove)),
placeholder_user_membership_path(placeholder_user_id: @placeholder_user, id: membership),
method: :delete,
title: t(:button_remove)) if membership.deletable? %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% else %>
<%= no_results_box %>
<% end %>
</div>
<div class="grid-content">
<% if projects.any? %>
<%= labelled_tabular_form_for(:membership,
url: placeholder_user_memberships_path(placeholder_user_id: @placeholder_user),
html: {id: "new_project_membership"}) do %>
<fieldset class="form--fieldset">
<legend class="form--fieldset-legend"><%=t(:label_project_new)%></legend>
<%= styled_select_tag 'membership[project_id]', options_for_membership_project_select(@placeholder_user, projects) %>
<div class="form--field -vertical">
<%= styled_label_tag nil, "#{t(:label_role_plural)}:" %>
<div class="form--field-container -vertical">
<%= labeled_check_box_tags 'membership[role_ids][]', roles %>
</div>
</div>
<div><%= styled_button_tag t(:button_add), class: '-highlight -with-icon icon-checkmark' %></div>
</fieldset>
<% end %>
<% end %>
</div>
</div>

@ -0,0 +1,36 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2020 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-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 docs/COPYRIGHT.rdoc for more details.
++#%>
<% html_title(t(:label_administration), "#{t(:label_edit)} #{PlaceholderUser.model_name.human} #{h(@placeholder_user.name)}") -%>
<% local_assigns[:additional_breadcrumb] = @placeholder_user.name %>
<%= render partial: 'users/toolbar', locals: { new_user: false, :@user => @placeholder_user } %>
<%= render_extensible_tabs :placeholder_user, placeholder_user: @placeholder_user %>

@ -0,0 +1,46 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2020 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-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 docs/COPYRIGHT.rdoc for more details.
++#%>
<% html_title t(:label_administration), t(:label_placeholder_user_plural) -%>
<%= toolbar title: t(:label_placeholder_user_plural), title_class: 'no-padding-bottom' do %>
<li class="toolbar-item">
<%= link_to new_placeholder_user_path,
{ class: 'button -alt-highlight',
aria: { label: t(:label_placeholder_user_new) },
title: t(:label_placeholder_user_new) } do %>
<%= op_icon('button--icon icon-add') %>
<span class="button--text"><%= t('activerecord.models.placeholder_user') %></span>
<% end %>
</li>
<%= call_hook(:user_admin_action_menu) %>
<% end %>
<%= cell PlaceholderUsers::PlaceholderUserFilterCell, params, groups: @groups %>
&nbsp;
<%= cell PlaceholderUsers::TableCell, @placeholder_users, project: @project, current_user: current_user %>

@ -0,0 +1,43 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2020 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-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 docs/COPYRIGHT.rdoc for more details.
++#%>
<% html_title t(:label_administration), t("label_placeholder_user_new") %>
<% local_assigns[:additional_breadcrumb] = t(:label_placeholder_user_new) %>
<%= render partial: 'users/toolbar', locals: { new_user: true, :@user => @placeholder_user } %>
<%= labelled_tabular_form_for @placeholder_user,
url: { action: "create" },
html: { class: nil, autocomplete: 'off' },
as: :placeholder_user do |f| %>
<%= render partial: 'form', locals: { f: f, placeholder_user: @placeholder_user } %>
<p>
<%= styled_button_tag t(:button_create), class: '-highlight -with-icon icon-checkmark' %>
<%= styled_button_tag t(:button_create_and_continue), name: 'continue', class: '-highlight -with-icon icon-checkmark' %>
</p>
<% end %>

@ -28,7 +28,7 @@ See docs/COPYRIGHT.rdoc for more details.
++#%>
<% groups = @user.groups.pluck(:id, :lastname) %>
<%= labelled_tabular_form_for(:user, url: { action: 'update' }, html: {method: :put}) do %>
<%= labelled_tabular_form_for(:user, url: { action: 'update' }, html: { method: :put }) do %>
<section class="form--section">
<% if groups.empty? %>
<%= no_results_box action_url: groups_path,

@ -26,9 +26,9 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See docs/COPYRIGHT.rdoc for more details.
++#%>
<%= breadcrumb_toolbar(@user.new_record? ? t(:label_user_new) : @user.name) do %>
<%= breadcrumb_toolbar(@user.new_record? ? t("label_#{@user.class.name.underscore}_new".to_sym) : @user.name) do %>
<% unless @user.new_record? %>
<% if current_user.admin? || current_user.id == @user.id %>
<% if (current_user.admin? || current_user.id == @user.id) && !@user.is_a?(PlaceholderUser) %>
<li class="toolbar-item hidden-for-mobile">
<%= form_for(@user, html: { class: 'toolbar-item'},
url: { action: :resend_invitation },
@ -46,7 +46,7 @@ See docs/COPYRIGHT.rdoc for more details.
<span class="button--text"><%= t(:label_profile) %></span>
<% end %>
</li>
<% unless current_user.id == @user.id %>
<% if current_user.id != @user.id && @user.is_a?(User) %>
<%= form_for @user, html: { class: 'toolbar-item hidden-for-mobile' }, :url => {:action => :change_status},
:method => :post do %>
<li>

@ -44,8 +44,8 @@ See docs/COPYRIGHT.rdoc for more details.
<li class="toolbar-item">
<%= link_to new_user_path,
{ class: 'button -alt-highlight',
aria: {label: t(:label_user_new)},
title: t(:label_user_new)} do %>
aria: { label: t(:label_user_new) },
title: t(:label_user_new) } do %>
<%= op_icon('button--icon icon-add') %>
<span class="button--text"><%= t('activerecord.models.user') %></span>
<% end %>

@ -144,6 +144,11 @@ Redmine::MenuManager.map :admin_menu do |menu|
caption: :label_user_plural,
parent: :users_and_permissions
menu.push :placeholder_users,
{ controller: '/placeholder_users' },
caption: :label_placeholder_user_plural,
parent: :users_and_permissions
menu.push :groups,
{ controller: '/groups' },
caption: :label_group_plural,

@ -268,6 +268,13 @@ en:
no_results_title_text: There is currently no news to report.
no_results_content_text: Add a news item
placeholder_users:
groups:
member_in_these_groups: 'This placeholder user is currently a member of the following groups:'
no_results_title_text: This placeholder user is currently not a member in any group.
memberships:
no_results_title_text: This placeholder user is currently not a member of a project.
users:
groups:
member_in_these_groups: 'This user is currently a member of the following groups:'
@ -773,6 +780,7 @@ en:
status: "Work package status"
member: "Member"
news: "News"
placeholder_user: "Placeholder user"
project: "Project"
query: "Custom query"
role:
@ -1534,6 +1542,7 @@ en:
label_my_account_data: "My account data"
label_my_projects: "My projects"
label_my_queries: "My custom queries"
label_name: "Name"
label_never: "Never"
label_new: "New"
label_new_features: "New features"
@ -1583,6 +1592,9 @@ en:
label_permissions: "Permissions"
label_permissions_report: "Permissions report"
label_personalize_page: "Personalize this page"
label_placeholder_user: "Placeholder user"
label_placeholder_user_new: "New placeholder user"
label_placeholder_user_plural: "Placeholder users"
label_planning: "Planning"
label_please_login: "Please log in"
label_plugins: "Plugins"

@ -1037,3 +1037,6 @@ en:
card: 'Cards'
list: 'Table'
timeline: 'Gantt'
invite_user_modal:
title: "Invite User"

@ -1,4 +1,5 @@
#-- encoding: UTF-8
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
@ -465,6 +466,17 @@ OpenProject::Application.routes.draw do
end
end
resources :placeholder_users do
resources :memberships, controller: 'placeholder_users/memberships', only: %i[update create destroy]
member do
match '/edit/:tab' => 'placeholder_users#edit', via: :get, as: 'tab_edit'
match '/change_status/:change_action' => 'users#change_status_info', via: :get, as: 'change_status_info'
post :change_status
get :deletion_info
end
end
scope controller: 'users_settings' do
get 'users_settings' => 'users_settings#index'
post 'users_settings' => 'users_settings#edit'

@ -32,25 +32,6 @@ x-op-frontend-build: &frontend-build
DEV_GID: $DEV_GID
services:
db:
image: postgres:10
<<: *restart_policy
stop_grace_period: "3s"
volumes:
- "pgdata:/var/lib/postgresql/data"
environment:
POSTGRES_USER: ${DB_USERNAME:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
POSTGRES_DB: ${DB_DATABASE:-openproject}
networks:
- network
cache:
image: memcached
<<: *restart_policy
networks:
- network
backend:
build:
<<: *build
@ -94,6 +75,25 @@ services:
depends_on:
- backend
db:
image: postgres:10
<<: *restart_policy
stop_grace_period: "3s"
volumes:
- "pgdata:/var/lib/postgresql/data"
environment:
POSTGRES_USER: ${DB_USERNAME:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
POSTGRES_DB: ${DB_DATABASE:-openproject}
networks:
- network
cache:
image: memcached
<<: *restart_policy
networks:
- network
######### Testing stuff below ############
db-test:

@ -76,6 +76,7 @@ import {globalDynamicComponents} from "core-app/global-dynamic-components.const"
import {OpenprojectMembersModule} from "core-app/modules/members/members.module";
import {OpenprojectEnterpriseModule} from "core-components/enterprise/openproject-enterprise.module";
import {OpenprojectAugmentingModule} from "core-app/modules/augmenting/openproject-augmenting.module";
import {OpenprojectInviteUserModalModule} from "core-app/modules/invite-user-modal/invite-user-modal.module";
import {RevitAddInSettingsButtonService} from "core-app/modules/bim/revit_add_in/revit-add-in-settings-button.service";
@NgModule({
@ -138,6 +139,9 @@ import {RevitAddInSettingsButtonService} from "core-app/modules/bim/revit_add_in
// Augmenting Module
OpenprojectAugmentingModule,
// Invite user modal
OpenprojectInviteUserModalModule,
],
providers: [
{ provide: States, useValue: new States() },

@ -6,6 +6,7 @@ import {APIV3Service} from "core-app/modules/apiv3/api-v3.service";
export interface UserLike {
name:string;
id:string|number|null;
href:string|null;
}
@Injectable({ providedIn: 'root' })
@ -49,14 +50,25 @@ export class UserAvatarRendererService {
user:UserLike,
renderName:boolean = true,
classes:string = 'avatar-medium'):void {
const userInitials = this.getInitials(user.name);
const colorCode = this.colors.toHsl(user.name);
let fallback = document.createElement('div');
fallback.className = classes;
fallback.textContent = this.getInitials(user.name)
fallback.classList.add('avatar-default');
fallback.textContent = userInitials;
fallback.style.background = colorCode;
// Todo: move this matcher to HAL ressource
if (user.href && user.href.includes('/placeholder_users/')) {
fallback.classList.add("-placeholder-user");
fallback.style.color = colorCode;
fallback.style.borderColor = colorCode;
fallback.style.background = 'transparent';
} else if (user.href && user.href.includes('/groups/')) {
fallback.classList.add("-group");
fallback.style.background = colorCode;
} else {
fallback.classList.add("-user");
fallback.style.background = colorCode;
}
container.appendChild(fallback);

@ -49,7 +49,7 @@ export class UserAvatarComponent implements AfterViewInit {
public ngAfterViewInit() {
const element = this.elementRef.nativeElement;
let user = this.user || { name: element.dataset.userName!, id: element.dataset.userId };
let user = this.user || { name: element.dataset.userName!, id: element.dataset.userId, href: null };
this.avatarRenderer.render(element, user, false, element.dataset.classList);
}
}

@ -29,6 +29,7 @@
import {NgModule} from '@angular/core';
import {OpenprojectCommonModule} from "core-app/modules/common/openproject-common.module";
import {OpenprojectWorkPackagesModule} from "core-app/modules/work_packages/openproject-work-packages.module";
import {OpenprojectInviteUserModalModule} from "core-app/modules/invite-user-modal/invite-user-modal.module";
import {UIRouterModule} from "@uirouter/angular";
import {BoardListComponent} from "core-app/modules/boards/board/board-list/board-list.component";
import {BoardsRootComponent} from "core-app/modules/boards/boards-root/boards-root.component";

@ -84,6 +84,7 @@ import {TimeEntryWorkPackageAutocompleterComponent} from "core-app/modules/commo
import {DraggableAutocompleteComponent} from "core-app/modules/common/draggable-autocomplete/draggable-autocomplete.component";
import {DragulaModule} from "ng2-dragula";
import {SlideToggleComponent} from "core-app/modules/common/slide-toggle/slide-toggle.component";
import {OpOptionListComponent} from "core-app/modules/common/option-list/option-list.component";
export function bootstrapModule(injector:Injector) {
// Ensure error reporter is run
@ -149,6 +150,7 @@ export function bootstrapModule(injector:Injector) {
OpDatePickerComponent,
OpDateTimeComponent,
OpIcon,
OpOptionListComponent,
AutofocusDirective,
FocusWithinDirective,
@ -202,6 +204,7 @@ export function bootstrapModule(injector:Injector) {
OpDatePickerComponent,
OpDateTimeComponent,
OpIcon,
OpOptionListComponent,
AutofocusDirective,
FocusWithinDirective,

@ -0,0 +1,22 @@
<!-- This is an example structure -->
<div class="op-option-list">
<label
*ngFor="let option of options"
class="op-option-list--item"
[ngClass]="option.value === selected ? 'op-option-list--item_selected' : ''"
>
<input
type="radio"
[attr.name]="name"
[value]="option.value"
[(ngModel)]="selectedInternal"
/>
<div>
<p class="op-option-list--title">{{ option.title }}</p>
<p
*ngIf="option.description"
class="op-option-list--description"
>{{ option.description }}</p>
</div>
</label>
</div>

@ -0,0 +1,17 @@
.op-option-list
display: flex
flex-direction: column
font-size: 1rem
&--item
padding: 1rem 0.75rem
display: flex
border: 1px solid #cbd5e0
background: #f7fafc
&:not(:last-child)
margin-bottom: 0.5rem
&_selected
border: 1px solid #90cdf4
background: #ebf8ff

@ -0,0 +1,48 @@
import {
Component,
Input,
Output,
EventEmitter,
} from "@angular/core";
import { ControlValueAccessor } from "@angular/forms";
export interface IOpOptionListOption<T> {
value:T;
title:string;
description?:string;
}
export type IOpOptionListValue<T> = T|null;
@Component({
// Style is imported globally
templateUrl: './option-list.component.html',
selector: 'op-option-list',
})
export class OpOptionListComponent<T> implements ControlValueAccessor {
@Input() options:IOpOptionListOption<T>[] = [];
@Input() name:string = `op-option-list-${+(new Date())}`;
@Output() selectedChange = new EventEmitter<T>();
private _selected:IOpOptionListValue<T> = null;
get selected() {
return this._selected;
}
set selected(data:IOpOptionListValue<T>) {
this.onChange(data);
}
onChange = (_:IOpOptionListValue<T>) => {};
onTouched = (_:IOpOptionListValue<T>) => {};
writeValue(value:IOpOptionListValue<T>) {
this._selected = value;
}
registerOnChange(fn:any) {
this.onChange = fn;
}
registerOnTouched(fn:any) {
this.onTouched = fn;
}
}

@ -258,7 +258,13 @@ export class SelectEditFieldComponent extends EditFieldComponent implements OnIn
}
protected mapAllowedValue(value:HalResource):ValueOption {
return { name: value.name, $href: value.$href };
let option = { name: value.name, $href: value.$href };
if (value._type === "PlaceholderUser") {
option.name = `${value.name} - Placeholder`;
} else if (value._type === "Group") {
option.name = `${value.name} - Group`;
}
return option;
}
// Subclasses shall be able to override the filters with which the

@ -0,0 +1,15 @@
import {Component, ElementRef, OnInit} from '@angular/core';
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
@Component({
selector: 'op-ium-group',
templateUrl: './group.component.html',
styleUrls: ['./group.component.sass'],
})
export class InviteGroupComponent implements OnInit {
constructor(readonly I18n:I18nService,
readonly elementRef:ElementRef) {}
ngOnInit() {
}
}

@ -0,0 +1,34 @@
import {NgModule} from "@angular/core";
import {ReactiveFormsModule} from "@angular/forms";
import {InviteUserModalComponent} from "./invite-user.component";
import {InviteProjectSelectionComponent} from "./project-selection.component";
import {InviteUserComponent} from "./user.component";
import {InviteGroupComponent} from "./group.component";
import {InvitePlaceholderComponent} from "./placeholder.component";
import {InviteRoleComponent} from "./role.component";
import {InviteMessageComponent} from "./message.component";
import {InviteSuccessComponent} from "./success.component";
import {InviteSummaryComponent} from "./summary.component";
import {NgSelectModule} from "@ng-select/ng-select";
import {OpenprojectCommonModule} from "core-app/modules/common/openproject-common.module";
@NgModule({
imports: [
OpenprojectCommonModule,
NgSelectModule,
ReactiveFormsModule,
],
exports: [],
declarations: [
InviteUserModalComponent,
InviteProjectSelectionComponent,
InviteUserComponent,
InviteGroupComponent,
InvitePlaceholderComponent,
InviteRoleComponent,
InviteMessageComponent,
InviteSuccessComponent,
InviteSummaryComponent,
]
})
export class OpenprojectInviteUserModalModule { }

@ -0,0 +1,17 @@
<div class="op-modal--portal">
<op-ium-project-selection
*ngIf="step === 'project-selection'"
class="op-modal--modal-container"
[project]="project"
[type]="type"
(save)="onProjectSelectionSave"
(close)="close"
></op-ium-project-selection>
<op-ium-user
*ngIf="step === 'user' && type === 'user'"
class="op-modal--modal-container"
[user]="principal"
(save)="onUserSave"
(back)="back"
></op-ium-user>
</div>

@ -0,0 +1,60 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Inject, OnInit} from '@angular/core';
import {OpModalLocalsMap} from 'core-components/op-modals/op-modal.types';
import {OpModalComponent} from 'core-components/op-modals/op-modal.component';
import {OpModalLocalsToken} from "core-components/op-modals/op-modal.service";
import * as URI from 'urijs';
import {HttpClient} from '@angular/common/http';
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {Observable} from 'rxjs';
@Component({
templateUrl: './invite-user.component.html',
styleUrls: ['./invite-user.component.sass'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InviteUserModalComponent extends OpModalComponent implements OnInit {
private steps = [
'project-selection',
'username',
'role',
'message',
'summary',
'success',
];
private stepIndex = 0;
/* Close on escape? */
public closeOnEscape = true;
/* Close on outside click */
public closeOnOutsideClick = true;
public type:string|null = null;
public project = null;
public principal = null;
public role = null;
public message = '';
public get step() {
return this.steps[this.stepIndex];
}
constructor(@Inject(OpModalLocalsToken) public locals:OpModalLocalsMap,
readonly cdRef:ChangeDetectorRef,
readonly elementRef:ElementRef,
readonly httpClient:HttpClient) {
super(locals, cdRef, elementRef);
}
ngOnInit() {
super.ngOnInit();
}
onProjectSelectionSave() {
console.log('select project');
}
back() {
this.stepIndex = Math.max(this.stepIndex - 1, 0);
}
}

@ -0,0 +1,15 @@
import {Component, ElementRef, OnInit} from '@angular/core';
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
@Component({
selector: 'op-ium-message',
templateUrl: './message.component.html',
styleUrls: ['./message.component.sass'],
})
export class InviteMessageComponent implements OnInit {
constructor(readonly I18n:I18nService,
readonly elementRef:ElementRef) {}
ngOnInit() {
}
}

@ -0,0 +1,15 @@
import {Component, ElementRef, OnInit} from '@angular/core';
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
@Component({
selector: 'op-ium-placeholder',
templateUrl: './placeholder.component.html',
styleUrls: ['./placeholder.component.sass'],
})
export class InvitePlaceholderComponent implements OnInit {
constructor(readonly I18n:I18nService,
readonly elementRef:ElementRef) {}
ngOnInit() {
}
}

@ -0,0 +1,33 @@
<form
[formGroup]="projectAndTypeForm"
ngSubmit="onSubmit"
>
<div class="op-modal--modal-container">
<div class="op-modal--modal-header">
<button
class="op-modal--modal-close-button"
type="button"
(click)="close"
[attr.aria-label]="text.closePopup"
>
<span class="icon-close"></span>
</button>
<h3 class="icon-context icon-attention" [textContent]="text.title"></h3>
</div>
<div class="op-modal--modal-body">
<op-option-list
[options]="typeOptions"
formControlName="type"
></op-option-list>
</div>
<div class="op-modal--modal-footer">
<button
type="button"
(click)="back"
>Back</button>
<button>Next</button>
</div>
</div>
</form>

@ -0,0 +1,75 @@
import {
Component,
Input,
EventEmitter,
Output,
ElementRef,
} from '@angular/core';
import {
FormControl,
FormGroup,
Validators,
} from '@angular/forms';
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
@Component({
selector: 'op-ium-project-selection',
templateUrl: './project-selection.component.html',
styleUrls: ['./project-selection.component.sass'],
})
export class InviteProjectSelectionComponent {
public text = {
title: this.I18n.t('js.invite_user_modal.title'),
closePopup: this.I18n.t('js.close_popup_title'),
exportPreparing: this.I18n.t('js.label_export_preparing')
};
public typeOptions = [
{
value: 'user',
title: 'User',
description: 'Permissions based on the assigned role in the selected project'
},
{
value: 'group',
title: 'Group',
description: 'Permissions based on the assigned role in the selected project'
},
{
value: 'placeholder',
title: 'Placeholder',
description: 'Has no access to the proejct and no emails are sent out'
},
];
@Input('type') type:string;
@Input('project') project:null;
projectAndTypeForm = new FormGroup({
type: new FormControl('', [ Validators.required ]),
project: new FormControl(null, [ Validators.required ]),
});
@Output('close') closeModal = new EventEmitter<void>();
@Output() save = new EventEmitter<{project:any, type:string}>();
constructor(readonly I18n:I18nService,
readonly elementRef:ElementRef) {}
close() {
this.closeModal.emit();
}
onSubmit($e:Event) {
console.log(this.projectAndTypeForm);
debugger;
this.save.emit({
project: this.projectAndTypeForm.get('project'),
type: this.projectAndTypeForm.get('type'),
});
}
back() {
}
}

@ -0,0 +1,15 @@
import {Component, ElementRef, OnInit} from '@angular/core';
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
@Component({
selector: 'op-ium-role',
templateUrl: './role.component.html',
styleUrls: ['./role.component.sass'],
})
export class InviteRoleComponent implements OnInit {
constructor(readonly I18n:I18nService,
readonly elementRef:ElementRef) {}
ngOnInit() {
}
}

@ -0,0 +1,15 @@
import {Component, ElementRef, OnInit} from '@angular/core';
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
@Component({
selector: 'op-ium-success',
templateUrl: './success.component.html',
styleUrls: ['./success.component.sass'],
})
export class InviteSuccessComponent implements OnInit {
constructor(readonly I18n:I18nService,
readonly elementRef:ElementRef) {}
ngOnInit() {
}
}

@ -0,0 +1,15 @@
import {Component, ElementRef, OnInit} from '@angular/core';
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
@Component({
selector: 'op-ium-summary',
templateUrl: './summary.component.html',
styleUrls: ['./summary.component.sass'],
})
export class InviteSummaryComponent implements OnInit {
constructor(readonly I18n:I18nService,
readonly elementRef:ElementRef) {}
ngOnInit() {
}
}

@ -0,0 +1,15 @@
import {Component, ElementRef, OnInit} from '@angular/core';
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
@Component({
selector: 'op-ium-user',
templateUrl: './user.component.html',
styleUrls: ['./user.component.sass'],
})
export class InviteUserComponent implements OnInit {
constructor(readonly I18n:I18nService,
readonly elementRef:ElementRef) {}
ngOnInit() {
}
}

@ -17,6 +17,9 @@
<ul class="toolbar-items hide-when-print"
*ngIf="showToolbar">
<li>
<button (click)="openInviteUserModal()">Invite User</button>
</li>
<ng-container *ngFor="let definition of toolbarButtonComponents">
<li class="toolbar-item"

@ -39,6 +39,8 @@ import {ComponentType} from "@angular/cdk/overlay";
import {Ng2StateDeclaration} from "@uirouter/angular";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {WorkPackageFilterContainerComponent} from "core-components/filters/filter-container/filter-container.directive";
import {OpModalService} from 'core-app/components/op-modals/op-modal.service';
import {InviteUserModalComponent} from 'core-app/modules/invite-user-modal/invite-user.component';
export interface DynamicComponentDefinition {
component:ComponentType<any>;
@ -68,6 +70,7 @@ export class PartitionedQuerySpacePageComponent extends WorkPackagesViewBase imp
@InjectField() I18n!:I18nService;
@InjectField() titleService:OpTitleService;
@InjectField() queryParamListener:QueryParamListenerService;
@InjectField() opModalService:OpModalService;
text:{ [key:string]:string } = {
'jump_to_pagination': this.I18n.t('js.work_packages.jump_marks.pagination'),
@ -257,6 +260,15 @@ export class PartitionedQuerySpacePageComponent extends WorkPackagesViewBase imp
});
}
protected inviteModal = InviteUserModalComponent;
openInviteUserModal() {
const inviteModal = this.opModalService.show(this.inviteModal, 'global');
inviteModal.closingEvent.subscribe((modal:any) => {
console.log('Modal closed!', modal);
});
}
protected loadFirstPage():Promise<QueryResource> {
if (this.currentQuery) {
return this.wpListService.reloadQuery(this.currentQuery, this.projectIdentifier).toPromise();

@ -62,6 +62,14 @@
cursor: inherit
user-select: none
&.-placeholder-user
border: 1px dashed
background: none
&.-group
border: 1px solid white
box-shadow: 4px 0px 0px -1px #cccccc
h1, h2, h3, h4
user-avatar
vertical-align: middle

@ -2,4 +2,6 @@
@import "openproject/_index.sass"
@import "vendor/_index.sass"
@import "layout/_index.sass"
@import "content/_index.sass"
@import "content/_index.sass"
@import "../app/modules/common/option-list/option-list.component";

@ -0,0 +1,144 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-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 docs/COPYRIGHT.rdoc for more details.
#++
module API
module V3
module PlaceholderUsers
class PlaceholderUserRepresenter < ::API::V3::Principals::PrincipalRepresenter
include AvatarHelper
cached_representer dependencies: ->(*) { avatar_cache_dependencies }
def self.create(user, current_user:)
new(user, current_user: current_user)
end
def initialize(user, current_user:)
super(user, current_user: current_user)
end
self_link
link :updateImmediately,
cache_if: -> { current_user_is_admin } do
{
href: api_v3_paths.user(represented.id),
title: "Update #{represented.login}",
method: :patch
}
end
link :delete,
cache_if: -> { current_user_can_delete_represented? } do
{
href: api_v3_paths.user(represented.id),
title: "Delete #{represented.login}",
method: :delete
}
end
property :name,
exec_context: :decorator,
getter: ->(*) { represented.lastname },
setter: ->(fragment:, represented:, **) { represented.lastname = fragment },
render_nil: false,
cache_if: -> { current_user_is_admin_or_self }
property :avatar,
exec_context: :decorator,
getter: ->(*) { avatar_url(represented) },
render_nil: true
property :identity_url,
exec_context: :decorator,
as: 'identityUrl',
getter: ->(*) { represented.identity_url },
setter: ->(fragment:, represented:, **) { represented.identity_url = fragment },
render_nil: true,
cache_if: -> { current_user_is_admin_or_self }
##
# Used while parsing JSON to initialize `auth_source_id` through the given link.
def initialize_embedded_links!(data)
auth_source_id = parse_auth_source_id data, "auth_source"
if auth_source_id
auth_source = AuthSource.find_by_unique auth_source_id
id = auth_source ? auth_source.id : 0
# set id to 0 (as opposed to nil) to produce an auth source not found
# error further down the line in the user's base contract
represented.auth_source_id = id
end
end
##
# Overrides Roar::JSON::HAL::Resources#from_hash
def from_hash(hash, *)
if hash["_links"]
initialize_embedded_links! hash
end
super
end
def parse_auth_source_id(data, link_name)
value = data.dig("_links", link_name, "href")
if value
::API::Utilities::ResourceLinkParser.parse_id(
value,
property: :auth_source,
expected_version: "3",
expected_namespace: "auth_sources"
)
end
end
def _type
'PlaceholderUser'
end
def current_user_can_delete_represented?
current_user && ::Users::DeleteService.deletion_allowed?(represented, current_user)
end
private
##
# Dependencies required to cache users with avatars
# Extended by plugin
def avatar_cache_dependencies
[]
end
end
end
end
end

@ -43,6 +43,8 @@ module API
:user
when Group
:group
when PlaceholderUser
:placeholder_user
when NilClass
# Fall back to user for unknown principal
# since we do not have a principal route.
@ -62,7 +64,6 @@ module API
def self.getter(name)
->(*) {
next unless embed_links
instance = represented.send(name)
case instance
@ -70,6 +71,8 @@ module API
::API::V3::Users::UserRepresenter.new(represented.send(name), current_user: current_user)
when Group
::API::V3::Groups::GroupRepresenter.new(represented.send(name), current_user: current_user)
when PlaceholderUser
::API::V3::PlaceholderUsers::PlaceholderUserRepresenter.new(represented.send(name), current_user: current_user)
when NilClass
nil
else
@ -78,7 +81,7 @@ module API
}
end
def self.setter(name, property_name: name, namespaces: %i(groups users))
def self.setter(name, property_name: name, namespaces: %i(groups users placeholder_users))
->(fragment:, **) {
link = ::API::Decorators::LinkObject.new(represented,
property_name: property_name,

@ -31,7 +31,7 @@
module API
module V3
module Principals
module GroupOrUserElements
module GroupOrUserOrPlaceholderUserElements
extend ::ActiveSupport::Concern
included do
@ -41,6 +41,8 @@ module API
representer_class = case model
when User
::API::V3::Users::UserRepresenter
when PlaceholderUser
::API::V3::PlaceholderUsers::PlaceholderUserRepresenter
when Group
::API::V3::Groups::GroupRepresenter
else

@ -38,7 +38,7 @@ module API
module V3
module Users
class PaginatedUserCollectionRepresenter < ::API::Decorators::OffsetPaginatedCollection
include API::V3::Principals::GroupOrUserElements
include API::V3::Principals::GroupOrUserOrPlaceholderUserElements
end
end
end

@ -31,7 +31,7 @@ module API
module V3
module Users
class UserCollectionRepresenter < ::API::Decorators::UnpaginatedCollection
include API::V3::Principals::GroupOrUserElements
include API::V3::Principals::GroupOrUserOrPlaceholderUserElements
end
end
end

@ -378,6 +378,10 @@ module API
"#{root}/groups/#{id}"
end
def self.placeholder_user(id)
"#{root}/placeholder_users/#{id}"
end
resources :version
def self.versions_available_projects

@ -34,7 +34,7 @@ module API
class PrincipalSetter
def self.lambda(name, property_name = name)
->(args) {
expected_namespaces = Setting.work_package_group_assignment? ? %i(groups users) : %i(users)
expected_namespaces = Setting.work_package_group_assignment? ? %i(groups users placeholder_users) : %i(users)
lambda = ::API::V3::Principals::AssociatedSubclassLambda
.setter(name,

@ -33,7 +33,8 @@ module OpenProject
class << self
def tabs
@tabs ||= {
user: core_user_tabs
user: core_user_tabs,
placeholder_user: core_placeholder_user_tabs
}
end
@ -82,6 +83,30 @@ module OpenProject
}
]
end
def core_placeholder_user_tabs
[
{
name: 'general',
partial: 'placeholder_users/general',
path: ->(params) { tab_edit_placeholder_user_path(params[:placeholder_user], tab: :general) },
label: :label_general
},
{
name: 'memberships',
partial: 'placeholder_users/memberships',
path: ->(params) { tab_edit_placeholder_user_path(params[:placeholder_user], tab: :memberships) },
label: :label_project_plural
},
{
name: 'groups',
partial: 'placeholder_users/groups',
path: ->(params) { tab_edit_placeholder_user_path(params[:placeholder_user], tab: :groups) },
label: :label_group_plural,
if: ->(*) { Group.all.any? }
}
]
end
end
end
end

Loading…
Cancel
Save