diff --git a/app/components/work_packages/share/modal_body_component.sass b/app/components/work_packages/share/modal_body_component.sass index d29d6c986f2d..4ba5f5ee53c9 100644 --- a/app/components/work_packages/share/modal_body_component.sass +++ b/app/components/work_packages/share/modal_body_component.sass @@ -1,14 +1,14 @@ .op-share-wp-modal-body &--user-row display: grid - grid-template-columns: 1fr - grid-template-areas: "user" + grid-template-columns: minmax(31px, auto) 1fr // 31px is the width needed to display a group avatar + grid-template-areas: "avatar user_details" grid-column-gap: 10px &_manageable display: grid - grid-template-columns: 20px 1fr auto auto - grid-template-areas: "selection user button remove" + grid-template-columns: 20px minmax(31px, auto) 1fr auto auto + grid-template-areas: "selection avatar user_details button remove" grid-column-gap: 10px &--header diff --git a/app/components/work_packages/share/share_row_component.html.erb b/app/components/work_packages/share/share_row_component.html.erb index 2d5956ab896b..04bda610abe1 100644 --- a/app/components/work_packages/share/share_row_component.html.erb +++ b/app/components/work_packages/share/share_row_component.html.erb @@ -12,8 +12,12 @@ end end - user_row_grid.with_area(:user, tag: :div, classes: 'ellipsis') do - render(Users::AvatarComponent.new(user: principal, size: :medium)) + user_row_grid.with_area(:avatar, tag: :div) do + render(Users::AvatarComponent.new(user: principal, show_name: false, size: :medium)) + end + + user_row_grid.with_area(:user_details, tag: :div, classes: 'ellipsis') do + render(WorkPackages::Share::UserDetailsComponent.new(share:, manager_mode: share_editable?)) end if share_editable? diff --git a/app/components/work_packages/share/user_details_component.html.erb b/app/components/work_packages/share/user_details_component.html.erb new file mode 100644 index 000000000000..6ad99a059151 --- /dev/null +++ b/app/components/work_packages/share/user_details_component.html.erb @@ -0,0 +1,67 @@ +<%= + component_wrapper do + flex_layout do |flex| + flex.with_row do + render(Primer::Beta::Link.new(font_weight: :semibold, href: principal_show_path)) { user.name } + end + + flex.with_row(classes: 'ellipsis') do + if manager_mode? + if user_is_a_group? + if project_group? + render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("work_package.sharing.user_details.project_group")} + else + render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("work_package.sharing.user_details.not_project_group")} + end + else + if user_in_non_active_status? + if user.locked? + concat(render(Primer::Beta::Octicon.new(icon: :lock, color: :muted, mr: 1))) + concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("work_package.sharing.user_details.locked") }) + elsif user.invited? + if invite_resent? + concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("work_package.sharing.user_details.invite_resent") }) + else + concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t('work_package.sharing.user_details.invited') }) + concat( + form_with(url: resend_invite_path, method: :post) do + render(Primer::Beta::Button.new(type: :submit, px: 0, scheme: :link)) { I18n.t('work_package.sharing.user_details.resend_invite') } + end + ) + end + end + else + if part_of_a_group? + if part_of_a_shared_group? + if project_member? + concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("work_package.sharing.user_details.additional_privileges_project_or_group") }) + else + concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("work_package.sharing.user_details.additional_privileges_group") }) + end + else + if inherited_project_member? + concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("work_package.sharing.user_details.additional_privileges_project_or_group") }) + elsif project_member? + concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("work_package.sharing.user_details.additional_privileges_project") }) + else + concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("work_package.sharing.user_details.not_project_member") }) + end + end + else + if project_member? + concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("work_package.sharing.user_details.additional_privileges_project") }) + else + concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("work_package.sharing.user_details.not_project_member") }) + end + end + end + end + else + if user.invited? + concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("work_package.sharing.user_details.invited")}) + end + end + end + end + end +%> diff --git a/app/components/work_packages/share/user_details_component.rb b/app/components/work_packages/share/user_details_component.rb new file mode 100644 index 000000000000..a9c549c78530 --- /dev/null +++ b/app/components/work_packages/share/user_details_component.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2023 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. +# ++ + +module WorkPackages + module Share + # rubocop:disable OpenProject/AddPreviewForViewComponent + class UserDetailsComponent < ApplicationComponent + # rubocop:enable OpenProject/AddPreviewForViewComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + include WorkPackages::Share::Concerns::DisplayableRoles + + def initialize(share:, + manager_mode: User.current.allowed_in_project?(:share_work_packages, share.project), + invite_resent: false) + super + + @share = share + @user = share.principal + @manager_mode = manager_mode + @invite_resent = invite_resent + end + + private + + attr_reader :user, :share + + def manager_mode? = @manager_mode + + def invite_resent? = @invite_resent + + def wrapper_uniq_by + share.id + end + + def authoritative_work_package_role_name + @authoritative_work_package_role_name = options.find do |option| + option[:value] == share.roles.first.builtin + end[:label] + end + + def principal_show_path + case user + when User + user_path(user) + when Group + show_group_path(user) + else + placeholder_user_path(user) + end + end + + def resend_invite_path + resend_invite_work_package_share_path(share.entity, share) + end + + def user_is_a_group? + @user_is_a_group ||= user.is_a?(Group) + end + + def user_in_non_active_status? + user.locked? || user.invited? + end + + # Is a user member of a project no matter whether inherited or directly assigned + def project_member? + Member.exists?(project: share.project, + principal: user, + entity: nil) + end + + # Explicitly check whether the project membership was inherited by a group + def inherited_project_member? + Member.includes(:roles) + .references(:member_roles) + .where(project: share.project, principal: user, entity: nil) # membership in the project + .merge(MemberRole.only_inherited) # that was inherited + .any? + end + + def project_group? + user_is_a_group? && project_member? + end + + def part_of_a_shared_group? + share.member_roles.where.not(inherited_from: nil).any? + end + + def part_of_a_group? + GroupUser.where(user_id: user.id).any? + end + + def project_role_name + Member.where(project: share.project, + principal: user, + entity: nil) + .first + .roles + .first + .name + end + end + end +end diff --git a/app/controllers/work_packages/shares_controller.rb b/app/controllers/work_packages/shares_controller.rb index 3b399d3e4088..f7235f61312f 100644 --- a/app/controllers/work_packages/shares_controller.rb +++ b/app/controllers/work_packages/shares_controller.rb @@ -30,8 +30,8 @@ class WorkPackages::SharesController < ApplicationController include OpTurbo::ComponentStream include MemberHelper - before_action :find_work_package, only: %i[index create] - before_action :find_share, only: %i[destroy update] + before_action :find_work_package, only: %i[index create resend_invite] + before_action :find_share, only: %i[destroy update resend_invite] before_action :find_project before_action :authorize before_action :enterprise_check, only: %i[index] @@ -94,6 +94,14 @@ def destroy end end + def resend_invite + OpenProject::Notifications.send(OpenProject::Events::WORK_PACKAGE_SHARED, + work_package_member: @share, + send_notifications: true) + + respond_with_update_user_details + end + private def enterprise_check @@ -150,6 +158,15 @@ def respond_with_remove_share respond_with_turbo_streams end + def respond_with_update_user_details + update_via_turbo_stream( + component: WorkPackages::Share::UserDetailsComponent.new(share: @share, + invite_resent: true) + ) + + respond_with_turbo_streams + end + def find_work_package @work_package = WorkPackage.find(params[:work_package_id]) end diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index 69f57581179c..ebc00bd90617 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -118,7 +118,7 @@ map.permission :share_work_packages, { - 'work_packages/shares': %i[index create destroy update], + 'work_packages/shares': %i[index create destroy update resend_invite], 'work_packages/shares/bulk': %i[update destroy] }, permissible_on: :project, diff --git a/config/locales/en.yml b/config/locales/en.yml index 60146d352e78..8285e1ce64b6 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3281,6 +3281,17 @@ en: Adding additional users will exceed the current limit. Please contact an administrator to increase the user limit to ensure external users are able to access this work package. warning_user_limit_reached_admin: > Adding additional users will exceed the current limit. Please upgrade your plan to be able to ensure external users are able to access this work package. + user_details: + locked: "Locked user" + invited: "Invite sent. " + resend_invite: "Resend." + invite_resent: "Invite has been resent" + not_project_member: "Not a project member" + project_group: "Group members might have additional privileges (as project members)" + not_project_group: "Group (shared with all members)" + additional_privileges_project: "Might have additional privileges (as project member)" + additional_privileges_group: "Might have additional privileges (as group member)" + additional_privileges_project_or_group: "Might have additional privileges (as project or group member)" working_days: info: > diff --git a/config/routes.rb b/config/routes.rb index c4fb99c4d53a..74cef3b16aed 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -471,6 +471,9 @@ # Rails managed sharing route resources :shares, controller: 'work_packages/shares', only: %i[index create] do + member do + post 'resend_invite' => 'work_packages/shares#resend_invite' + end collection do resource :bulk, controller: 'work_packages/shares/bulk', only: %i[update destroy], as: :shares_bulk end diff --git a/frontend/src/app/features/work-packages/components/wp-share-modal/wp-share.modal.html b/frontend/src/app/features/work-packages/components/wp-share-modal/wp-share.modal.html index d44879b6970b..995cf376bf0d 100644 --- a/frontend/src/app/features/work-packages/components/wp-share-modal/wp-share.modal.html +++ b/frontend/src/app/features/work-packages/components/wp-share-modal/wp-share.modal.html @@ -1,5 +1,5 @@