diff --git a/app/controllers/profiles/accounts_controller.rb b/app/controllers/profiles/accounts_controller.rb index eb86df6d14418ef58103d197f50f917d97fd78f2..6d2e7a3ff9b938316cdf53783eb5d94576dab47a 100644 --- a/app/controllers/profiles/accounts_controller.rb +++ b/app/controllers/profiles/accounts_controller.rb @@ -39,7 +39,24 @@ def generate_support_pin private def show_view_variables - {} + { + designated_account_manager: format_beneficiary_data(current_user.designated_account_manager), + designated_account_successor: format_beneficiary_data(current_user.designated_account_successor) + } + end + + # TODO: move to helper? + def format_beneficiary_data(beneficiary) + return unless beneficiary + + { + id: beneficiary.id, + name: beneficiary.name, + email: beneficiary.email, + relationship: beneficiary.relationship, + delete_path: profile_designated_beneficiary_path(beneficiary), + object: beneficiary + } end def find_identity(provider) diff --git a/app/controllers/profiles/designated_beneficiaries_controller.rb b/app/controllers/profiles/designated_beneficiaries_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..f5d74ce2742f5a748c6f055b74bc2152226ab333 --- /dev/null +++ b/app/controllers/profiles/designated_beneficiaries_controller.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Profiles + class DesignatedBeneficiariesController < Profiles::ApplicationController + before_action :find_designated_beneficiary, only: [:update, :destroy] + + feature_category :user_profile + + def create + @designated_beneficiary = current_user.designated_beneficiaries.build(designated_beneficiary_params) + + if @designated_beneficiary.save + flash[:success] = success_message(@designated_beneficiary.type, "created") + else + flash[:alert] = @designated_beneficiary.errors.messages.values.flatten.to_sentence + end + + redirect_to profile_account_path + end + + def update + if @designated_beneficiary.update(designated_beneficiary_params) + flash[:success] = success_message(@designated_beneficiary.type, "updated") + else + flash[:alert] = @designated_beneficiary.errors.messages.values.flatten.to_sentence + end + + redirect_to profile_account_path + end + + def destroy + type = @designated_beneficiary.type + + if @designated_beneficiary.destroy + flash[:success] = success_message(type, "deleted") + else + # We don't expect to reach there unless we set up before_destroy callbacks or dependent associations. + flash[:alert] = s_('Profiles|Failed to delete designated account beneficiary.') + end + + redirect_to profile_account_path, status: :see_other + end + + private + + def find_designated_beneficiary + @designated_beneficiary = current_user.designated_beneficiaries.find(params.require(:id)) + rescue ActiveRecord::RecordNotFound # Handle concurrent deletions gracefully (two browser tabs) + flash[:notice] = s_('Profiles|Designated account beneficiary already deleted.') + redirect_to profile_account_path, status: :see_other + end + + def designated_beneficiary_params + params.require(:users_designated_beneficiary).permit(:name, :email, :relationship, :type) + end + + # TODO: customize message according to designs + def success_message(type, action) + format(s_('Profiles|Designated account %{type} %{action} successfully.'), type: type, action: action) + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index cd2e538a50e289865c72e23663440789dfaa075c..cd1e52bba8b485a13a6546337e8c18ce530c482b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2768,6 +2768,16 @@ def composite_identity_enforced! @composite_identity_enforced_override = true end + # For now, we only support max one account manager, but we may change that + def designated_account_manager + designated_beneficiaries.manager.last + end + + # For now, we only support max one account successor, but we may change that + def designated_account_successor + designated_beneficiaries.successor.last + end + protected # override, from Devise::Validatable diff --git a/app/models/users/designated_beneficiary.rb b/app/models/users/designated_beneficiary.rb index 034e8e0b4fd8b7e454a5eaf0b4d0368a7b18272c..58586c3012f0b9190cb6a982d65f14fd088cce5d 100644 --- a/app/models/users/designated_beneficiary.rb +++ b/app/models/users/designated_beneficiary.rb @@ -2,6 +2,29 @@ module Users class DesignatedBeneficiary < ApplicationRecord + # Two types are very identical. The only difference is that successor has mandatory `relationship`. + # It's easier to keep them as a single class until we need deeper customization. + self.inheritance_column = nil # rubocop:disable Database/AvoidInheritanceColumn -- suppress single table inheritance + belongs_to :user + + enum :type, { + manager: 0, + successor: 1 + } + + validates :name, presence: true, length: { maximum: 255 } + validates :email, length: { maximum: 255 }, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true + validates :relationship, length: { maximum: 255 } + validates :relationship, presence: true, if: :successor? + validates :type, presence: true + validates :user_id, uniqueness: { + scope: :type, + message: ->(object, _data) do + # rubocop:disable Layout/LineLength -- This is more readable + format(_("Designated account %{type} already exists. You can edit or delete in the legacy contacts section below."), type: object.type) + # rubocop:enable Layout/LineLength + end + } end end diff --git a/app/views/profiles/accounts/_designated_account_manager.html.haml b/app/views/profiles/accounts/_designated_account_manager.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..23d25cb0c5cacd4cdf0f72fe2c38ec5c0dfadb88 --- /dev/null +++ b/app/views/profiles/accounts/_designated_account_manager.html.haml @@ -0,0 +1,46 @@ +- learn_more_link = link_to('', help_page_path('user/profile/account/account_succession.md'), target: '_blank', rel: 'noreferrer') + +-# rubocop: disable Lint/LiteralAsCondition -- TODO if there is an error, expand/show the form += render ::Layouts::CrudComponent.new(s_('Profiles|Designated account manager'), + description: safe_format(s_('Profiles|Assign an individual who has legal authority to manage your GitLab account in the event of your incapacity. %{link_start}Learn more%{link_end}.'), tag_pair(learn_more_link, :link_start, :link_end)), + form_options: { class: false ? '' : 'gl-hidden js-toggle-content' }, + options: { class: 'js-toggle-container js-token-card' }) do |c| + -# rubocop: enable Lint/LiteralAsCondition + - c.with_actions do + - if designated_account_manager.blank? + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-toggle-button js-toggle-content' }) do + = s_('Profiles|Add account manager') + - c.with_form do + - object = designated_account_manager.present? ? designated_account_manager[:object] : Users::DesignatedBeneficiary.new + - url = designated_account_manager.present? ? profile_designated_beneficiary_path(designated_account_manager[:id]) : profile_designated_beneficiaries_path + - method = designated_account_manager.present? ? :patch : :post + = gitlab_ui_form_for object, url: url, method: method, html: { class: 'gl-show-field-errors', autocomplete: 'off', aria: { live: 'assertive' }} do |f| + = f.hidden_field :type, value: 'manager' + .form-group + = f.label :name, s_('Profiles|Full name'), class: 'label-bold' + = f.text_field :name, class: 'form-control gl-form-input gl-form-input-xl', title: s_('Profiles|Full name is required.'), required: true + .form-group + = f.label :email, s_('Profiles|Email address (optional)'), class: 'label-bold' + = f.email_field :email, class: 'form-control gl-form-input gl-form-input-xl' + = render Pajamas::ButtonComponent.new(variant: :confirm, type: :submit) do + = designated_account_manager.present? ? s_('Profiles|Update account manager') : s_('Profiles|Add account manager') + = render Pajamas::ButtonComponent.new(type: :reset, button_options: { class: 'gl-ml-2 js-toggle-button' }) do + = _('Cancel') + - c.with_body do + - if designated_account_manager.present? + .table-holder + %table.table.b-table.gl-table.b-table-stacked-md{ role: 'table' } + %thead + %tr + %th= s_('Profiles|Full name') + %th= s_('Profiles|Email address') + %th= _('Actions') + %tbody + %tr + %td{ data: { label: s_('Profiles|Name') } }= designated_account_manager[:name] + %td{ data: { label: s_('Profiles|Email address') } }= designated_account_manager[:email] + %td{ data: { label: _('Actions') }, class: '!gl-py-3' } + = render Pajamas::ButtonComponent.new(icon: 'remove', href: designated_account_manager[:delete_path], method: :delete, button_options: { title: _('Delete'), class: 'has-tooltip', data: { title: s_('Profiles|Delete account manager?'), confirm: _('This will permanently delete the designated account manager.'), confirm_btn_variant: 'danger' }, aria: { label: s_('Profiles|Delete account manager') }}) + = render Pajamas::ButtonComponent.new(icon: 'pencil', button_options: { title: _('Edit'), class: 'gl-ml-2 js-toggle-button js-toggle-content has-tooltip' }) + - else + = s_('Profiles|No designated account manager assigned.') diff --git a/app/views/profiles/accounts/_designated_account_successor.html.haml b/app/views/profiles/accounts/_designated_account_successor.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..0f65225d885f12fdcfe4bc6d8606b9efb96c45d3 --- /dev/null +++ b/app/views/profiles/accounts/_designated_account_successor.html.haml @@ -0,0 +1,51 @@ +- learn_more_link = link_to('', help_page_path('user/profile/account/account_succession.md'), target: '_blank', rel: 'noreferrer') + +-# rubocop: disable Lint/LiteralAsCondition -- TODO if there is an error, expand/show the form += render ::Layouts::CrudComponent.new(s_('Profiles|Designated account successor'), + description: safe_format(s_('Profiles|Assign an individual who will have legal authority to assume ownership of your GitLab account upon your death. %{link_start}Learn more%{link_end}.'), tag_pair(learn_more_link, :link_start, :link_end)), + form_options: { class: false ? '' : 'gl-hidden js-toggle-content' }, + options: { class: 'gl-mt-5 js-toggle-container js-token-card' }) do |c| + -# rubocop: enable Lint/LiteralAsCondition + - c.with_actions do + - if designated_account_successor.blank? + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-toggle-button js-toggle-content' }) do + = s_('Profiles|Add account successor') + - c.with_form do + - object = designated_account_successor.present? ? designated_account_successor[:object] : Users::DesignatedBeneficiary.new + - url = designated_account_successor.present? ? profile_designated_beneficiary_path(designated_account_successor[:id]) : profile_designated_beneficiaries_path + - method = designated_account_successor.present? ? :patch : :post + = gitlab_ui_form_for object, url: url, method: method, html: { class: 'gl-show-field-errors', autocomplete: 'off', aria: { live: 'assertive' }} do |f| + = f.hidden_field :type, value: 'successor' + .form-group + = f.label :name, s_('Profiles|Full name'), class: 'label-bold' + = f.text_field :name, class: 'form-control gl-form-input gl-form-input-xl', title: s_('Profiles|Full name is required.'), required: true + .form-group + = f.label :relationship, s_('Profiles|Relationship with you'), class: 'label-bold' + = f.text_field :relationship, class: 'form-control gl-form-input gl-form-input-xl', title: s_('Profiles|Relationship is required.'), required: true + .form-group + = f.label :email, s_('Profiles|Email address (optional)'), class: 'label-bold' + = f.email_field :email, class: 'form-control gl-form-input gl-form-input-xl' + = render Pajamas::ButtonComponent.new(variant: :confirm, type: :submit) do + = designated_account_successor.present? ? s_('Profiles|Update account successor') : s_('Profiles|Add account successor') + = render Pajamas::ButtonComponent.new(type: :reset, button_options: { class: 'gl-ml-2 js-toggle-button' }) do + = _('Cancel') + - c.with_body do + - if designated_account_successor.present? + .table-holder + %table.table.b-table.gl-table.b-table-stacked-md{ role: 'table' } + %thead + %tr + %th= s_('Profiles|Full name') + %th= s_('Profiles|Email address') + %th= s_('Profiles|Relationship') + %th= _('Actions') + %tbody + %tr + %td{ data: { label: s_('Profiles|Name') } }= designated_account_successor[:name] + %td{ data: { label: s_('Profiles|Email address') } }= designated_account_successor[:email] + %td{ data: { label: s_('Profiles|Relationship') } }= designated_account_successor[:relationship] + %td{ data: { label: _('Actions') }, class: '!gl-py-3' } + = render Pajamas::ButtonComponent.new(icon: 'remove', href: profile_designated_beneficiary_path(designated_account_successor[:id]), method: :delete, button_options: { title: _('Delete'), class: 'has-tooltip', data: { title: s_('Profiles|Delete account successor?'), confirm: s_('Profiles|This will permanently delete the designated account successor.'), confirm_btn_variant: 'danger' }, aria: { label: s_('Profiles|Delete account successor') }}) + = render Pajamas::ButtonComponent.new(icon: 'pencil', button_options: { title: _('Edit'), class: 'gl-ml-2 js-toggle-button js-toggle-content has-tooltip' }) + - else + = s_('Profiles|No designated account successor assigned.') diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index b5984db6e7a111312c41f87d847b04ca792f1717..f636d34160b84648f3c2ec4d8f594af36a137939 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -77,6 +77,11 @@ - data = { initial_username: current_user.username, root_url: root_url, action_url: update_username_profile_path(format: :json) } #update-username{ data: data } += render ::Layouts::SettingsSectionComponent.new(s_('Profiles|Legacy contacts')) do |c| + - c.with_body do + = render 'designated_account_manager', designated_account_manager: local_assigns[:designated_account_manager] + = render 'designated_account_successor', designated_account_successor: local_assigns[:designated_account_successor] + = render ::Layouts::SettingsSectionComponent.new(s_('Profiles|Scheduled pipelines you own')) do |c| - c.with_description do = s_("Profiles|View, edit, or transfer ownership of your active scheduled pipelines.") diff --git a/config/routes/profile.rb b/config/routes/profile.rb index 921df57f53b8b3b54b31fdab4daddebc9b143453..ef6ade0e5f247059abe55ca5a95e345d3c5eda7a 100644 --- a/config/routes/profile.rb +++ b/config/routes/profile.rb @@ -22,6 +22,8 @@ post :generate_support_pin end + resources :designated_beneficiaries, only: [:create, :update, :destroy], path: 'designated-beneficiaries' + resource :notifications, only: [:show, :update] do scope( path: 'groups/*id', diff --git a/doc/user/profile/account/account_succession.md b/doc/user/profile/account/account_succession.md index b8de772c46b23a6359f99c26f5df44cdc87745ad..eab5989e3c35fc0dfca9a249db9808d9f831ccbf 100644 --- a/doc/user/profile/account/account_succession.md +++ b/doc/user/profile/account/account_succession.md @@ -4,8 +4,6 @@ group: Authentication info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments title: Designate an account successor or account manager description: Designate an account successor or account manager for your GitLab account. -ignore_in_report: true -noindex: true --- {{< details >}} diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 134f17b18cd6418165dce8481352e0bd93826356..ed6ae1a4379e9ed6b2de25309aac2f562ea4de2b 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -23737,6 +23737,9 @@ msgstr "" msgid "DesignManagement|of %{designs_count}" msgstr "" +msgid "Designated account %{type} already exists. You can edit or delete in the legacy contacts section below." +msgstr "" + msgid "Designs" msgstr "" @@ -50292,6 +50295,12 @@ msgstr "" msgid "Profiles|Achievements" msgstr "" +msgid "Profiles|Add account manager" +msgstr "" + +msgid "Profiles|Add account successor" +msgstr "" + msgid "Profiles|Add email address" msgstr "" @@ -50307,6 +50316,12 @@ msgstr "" msgid "Profiles|An error occurred while updating your username, please try again." msgstr "" +msgid "Profiles|Assign an individual who has legal authority to manage your GitLab account in the event of your incapacity. %{link_start}Learn more%{link_end}." +msgstr "" + +msgid "Profiles|Assign an individual who will have legal authority to assume ownership of your GitLab account upon your death. %{link_start}Learn more%{link_end}." +msgstr "" + msgid "Profiles|Avatar cropper" msgstr "" @@ -50367,9 +50382,33 @@ msgstr "" msgid "Profiles|Delete account" msgstr "" +msgid "Profiles|Delete account manager" +msgstr "" + +msgid "Profiles|Delete account manager?" +msgstr "" + +msgid "Profiles|Delete account successor" +msgstr "" + +msgid "Profiles|Delete account successor?" +msgstr "" + msgid "Profiles|Deleting an account has the following effects:" msgstr "" +msgid "Profiles|Designated account %{type} %{action} successfully." +msgstr "" + +msgid "Profiles|Designated account beneficiary already deleted." +msgstr "" + +msgid "Profiles|Designated account manager" +msgstr "" + +msgid "Profiles|Designated account successor" +msgstr "" + msgid "Profiles|Disconnect %{provider}" msgstr "" @@ -50403,6 +50442,9 @@ msgstr "" msgid "Profiles|Email address" msgstr "" +msgid "Profiles|Email address (optional)" +msgstr "" + msgid "Profiles|Email addresses" msgstr "" @@ -50433,6 +50475,9 @@ msgstr "" msgid "Profiles|Expires" msgstr "" +msgid "Profiles|Failed to delete designated account beneficiary." +msgstr "" + msgid "Profiles|Failed to generate new Support PIN." msgstr "" @@ -50448,6 +50493,9 @@ msgstr "" msgid "Profiles|Full name" msgstr "" +msgid "Profiles|Full name is required." +msgstr "" + msgid "Profiles|Generate New PIN" msgstr "" @@ -50490,6 +50538,9 @@ msgstr "" msgid "Profiles|Last used" msgstr "" +msgid "Profiles|Legacy contacts" +msgstr "" + msgid "Profiles|Linked emails" msgstr "" @@ -50505,12 +50556,21 @@ msgstr "" msgid "Profiles|Manage two-factor authentication" msgstr "" +msgid "Profiles|Name" +msgstr "" + msgid "Profiles|New Support PIN generated successfully." msgstr "" msgid "Profiles|No \"<\" or \">\" characters, please." msgstr "" +msgid "Profiles|No designated account manager assigned." +msgstr "" + +msgid "Profiles|No designated account successor assigned." +msgstr "" + msgid "Profiles|No file chosen." msgstr "" @@ -50559,6 +50619,15 @@ msgstr "" msgid "Profiles|Publicly visible private SSH keys can compromise your system." msgstr "" +msgid "Profiles|Relationship" +msgstr "" + +msgid "Profiles|Relationship is required." +msgstr "" + +msgid "Profiles|Relationship with you" +msgstr "" + msgid "Profiles|Remove avatar" msgstr "" @@ -50634,6 +50703,9 @@ msgstr "" msgid "Profiles|This information will appear on your profile." msgstr "" +msgid "Profiles|This will permanently delete the designated account successor." +msgstr "" + msgid "Profiles|Time settings" msgstr "" @@ -50652,6 +50724,12 @@ msgstr "" msgid "Profiles|Unverified secondary email addresses are automatically deleted after three days" msgstr "" +msgid "Profiles|Update account manager" +msgstr "" + +msgid "Profiles|Update account successor" +msgstr "" + msgid "Profiles|Update profile settings" msgstr "" @@ -67673,6 +67751,9 @@ msgstr "" msgid "This vulnerability was automatically resolved because its vulnerability type was disabled in this project or removed from GitLab's default ruleset. For details about SAST rule changes, see https://docs.gitlab.com/ee/user/application_security/sast/rules#important-rule-changes." msgstr "" +msgid "This will permanently delete the designated account manager." +msgstr "" + msgid "This will rebase all commits from the source branch onto the target branch." msgstr ""