From 9731068dea6eeceed050c5575e90f7d21fe3f225 Mon Sep 17 00:00:00 2001 From: David F Date: Thu, 11 Jun 2026 15:14:06 +0200 Subject: [PATCH] Primerize the administration user form. wp/72005 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../custom_field_field_component.html.erb | 5 - .../custom_field_section_component.html.erb | 11 -- app/components/users/form_component.html.erb | 33 +++++ app/components/users/form_component.rb | 95 ++++++++++++++ .../custom_fields/custom_field_rendering.rb | 18 ++- app/forms/users/form/attributes_form.rb | 112 ++++++++++++++++ .../users/form/authentication_source_form.rb | 80 +++++++++++ app/forms/users/form/password_form.rb | 124 ++++++++++++++++++ app/forms/users/form/preferences_form.rb | 87 ++++++++++++ app/helpers/users_helper.rb | 30 +++++ app/views/users/_consent.html.erb | 5 +- app/views/users/_form.html.erb | 51 ------- app/views/users/_general.html.erb | 44 +++---- app/views/users/_preferences.html.erb | 51 ------- app/views/users/_simple_form.html.erb | 40 ------ app/views/users/form/_admin_flag.html.erb | 4 - app/views/users/form/_preferences.html.erb | 4 - .../form/authentication/_auth_source.html.erb | 22 ---- .../form/authentication/_external.html.erb | 33 +++-- .../users/form/authentication/_form.html.erb | 15 --- .../form/authentication/_internal.html.erb | 12 -- .../_internal_password.html.erb | 71 ---------- .../form/built_in_fields/_firstname.html.erb | 7 - .../form/built_in_fields/_language.html.erb | 7 - .../form/built_in_fields/_lastname.html.erb | 7 - .../form/built_in_fields/_login.html.erb | 10 -- .../users/form/built_in_fields/_mail.html.erb | 7 - app/views/users/new.html.erb | 30 ++--- spec/components/users/form_component_spec.rb | 93 +++++++++++++ spec/features/users/edit_users_spec.rb | 2 +- spec/features/users/password_change_spec.rb | 6 +- .../custom_field_rendering_spec.rb | 22 ++++ .../forms/users/form/attributes_form_spec.rb | 55 ++++---- .../form/authentication_source_form_spec.rb | 63 +++++++++ spec/forms/users/form/password_form_spec.rb | 61 +++++++++ .../forms/users/form/preferences_form_spec.rb | 35 ++--- spec/helpers/users_helper_form_hooks_spec.rb | 74 +++++++++++ spec/views/users/edit.html.erb_spec.rb | 3 +- 38 files changed, 973 insertions(+), 456 deletions(-) delete mode 100644 app/components/users/form/custom_field_field_component.html.erb delete mode 100644 app/components/users/form/custom_field_section_component.html.erb create mode 100644 app/components/users/form_component.html.erb create mode 100644 app/components/users/form_component.rb create mode 100644 app/forms/users/form/attributes_form.rb create mode 100644 app/forms/users/form/authentication_source_form.rb create mode 100644 app/forms/users/form/password_form.rb create mode 100644 app/forms/users/form/preferences_form.rb delete mode 100644 app/views/users/_form.html.erb delete mode 100644 app/views/users/_preferences.html.erb delete mode 100644 app/views/users/_simple_form.html.erb delete mode 100644 app/views/users/form/_admin_flag.html.erb delete mode 100644 app/views/users/form/_preferences.html.erb delete mode 100644 app/views/users/form/authentication/_auth_source.html.erb delete mode 100644 app/views/users/form/authentication/_form.html.erb delete mode 100644 app/views/users/form/authentication/_internal.html.erb delete mode 100644 app/views/users/form/authentication/_internal_password.html.erb delete mode 100644 app/views/users/form/built_in_fields/_firstname.html.erb delete mode 100644 app/views/users/form/built_in_fields/_language.html.erb delete mode 100644 app/views/users/form/built_in_fields/_lastname.html.erb delete mode 100644 app/views/users/form/built_in_fields/_login.html.erb delete mode 100644 app/views/users/form/built_in_fields/_mail.html.erb create mode 100644 spec/components/users/form_component_spec.rb rename app/components/users/form/custom_field_field_component.rb => spec/forms/users/form/attributes_form_spec.rb (54%) create mode 100644 spec/forms/users/form/authentication_source_form_spec.rb create mode 100644 spec/forms/users/form/password_form_spec.rb rename app/components/users/form/custom_field_section_component.rb => spec/forms/users/form/preferences_form_spec.rb (61%) create mode 100644 spec/helpers/users_helper_form_hooks_spec.rb diff --git a/app/components/users/form/custom_field_field_component.html.erb b/app/components/users/form/custom_field_field_component.html.erb deleted file mode 100644 index b23e7671104..00000000000 --- a/app/components/users/form/custom_field_field_component.html.erb +++ /dev/null @@ -1,5 +0,0 @@ -<%= @form.fields_for_custom_fields :custom_field_values, @form.object, field_options do |cf_form| %> - <%= content_tag :div, class: css_classes do - cf_form.cf_form_field(container_class:) - end %> -<% end %> diff --git a/app/components/users/form/custom_field_section_component.html.erb b/app/components/users/form/custom_field_section_component.html.erb deleted file mode 100644 index 241e5c33ae2..00000000000 --- a/app/components/users/form/custom_field_section_component.html.erb +++ /dev/null @@ -1,11 +0,0 @@ -
- <%= title %> - - <% @section.attribute_order.each do |key| %> - <% if built_in?(key) %> - <%= render "users/form/built_in_fields/#{key}", f: @form, contract: @contract, user: @user %> - <% elsif (cf = visible_custom_field(key)) %> - <%= render Users::Form::CustomFieldFieldComponent.new(custom_field: cf, form: @form) %> - <% end %> - <% end %> -
diff --git a/app/components/users/form_component.html.erb b/app/components/users/form_component.html.erb new file mode 100644 index 00000000000..09581698f23 --- /dev/null +++ b/app/components/users/form_component.html.erb @@ -0,0 +1,33 @@ +<% if editing? %> + <%= render(Primer::Box.new(mb: 3)) do %> + <%= render(Primer::Beta::Text.new(tag: :div, font_weight: :bold)) { I18n.t("attributes.status") } %> + <%= render(Primer::Beta::Text.new(tag: :div)) { helpers.full_user_status(@user, true) } %> + <% end %> +<% end %> + +<%= render(form_list) %> + +<% if show_no_login_message? %> + <%= render(Primer::Beta::Text.new(tag: :p, color: :muted)) { I18n.t("user.no_login") } %> +<% end %> + +<% if show_external_auth? %> + <%= render("users/form/authentication/external", user: @user) %> +<% end %> + +<%= helpers.render_user_form_hooks(user: @user, form: @builder) %> + +<% if show_consent? %> + <%= render("users/consent", user: @user) %> +<% end %> + +<% if show_preferences? %> + <%= fields_for(:pref, @user.pref, builder: @builder.class) do |pref_f| %> + <%= render(Users::Form::PreferencesForm.new(pref_f)) %> + <% end %> +<% end %> + +<%= render(Primer::Beta::Button.new(type: :submit, scheme: :primary, name: "submit")) { creating? ? I18n.t(:button_create) : I18n.t(:button_save) } %> +<% if creating? %> + <%= render(Primer::Beta::Button.new(type: :submit, name: "continue")) { I18n.t(:button_create_and_continue) } %> +<% end %> diff --git a/app/components/users/form_component.rb b/app/components/users/form_component.rb new file mode 100644 index 00000000000..c19d35937bf --- /dev/null +++ b/app/components/users/form_component.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 Users + # Coordinates the administration user form: receives the surrounding + # `settings_primer_form_with` builder, composes the inner forms into a + # Primer::Forms::FormList and renders the read-only / plain bits (status, + # consent, external auth, hooks, submit) and the pref-scoped preferences form + # around it. Create vs edit is derived from `user.new_record?`. + class FormComponent < ApplicationComponent + def initialize(builder:, user:, contract:) + super() + @builder = builder + @user = user + @contract = contract + end + + private + + def creating? = @user.new_record? + def editing? = !creating? + + def form_list + Primer::Forms::FormList.new(*input_forms) + end + + def input_forms + forms = [Users::Form::AttributesForm.new(@builder, user: @user, contract: @contract)] + forms << Users::Form::AuthenticationSourceForm.new(@builder, user: @user) if show_auth_source? + if show_password? + forms << Users::Form::PasswordForm.new(@builder, user: @user, + assign_random_password_checked: assign_random_password_checked?) + end + forms + end + + def show_auth_source? + return false if editing? && @user.uses_external_authentication? + + creating? ? can_users_have_auth_source? : (User.current.admin? || can_users_have_auth_source?) + end + + def show_password? + editing? && User.current.admin? && !@user.uses_external_authentication? && !disable_password_login? + end + + def show_preferences? = editing? && User.current.admin? + def show_external_auth? = editing? && @user.uses_external_authentication? + + def show_no_login_message? + editing? && User.current.admin? && !@user.uses_external_authentication? && disable_password_login? + end + + def show_consent? = editing? && Setting.consent_required? + + def can_users_have_auth_source? + LdapAuthSource.any? && !disable_password_login? + end + + def disable_password_login? + OpenProject::Configuration.disable_password_login? + end + + def assign_random_password_checked? + helpers.params.dig(:user, :assign_random_password).present? + end + end +end diff --git a/app/forms/custom_fields/custom_field_rendering.rb b/app/forms/custom_fields/custom_field_rendering.rb index ba2ef874492..1fa122c9f5e 100644 --- a/app/forms/custom_fields/custom_field_rendering.rb +++ b/app/forms/custom_fields/custom_field_rendering.rb @@ -52,13 +52,17 @@ module CustomFields::CustomFieldRendering def render_custom_fields(form:) custom_fields.each do |custom_field| - form.fields_for(:custom_field_values) do |builder| - custom_field_input(builder, custom_field) - end - if custom_field.has_comment? - form.fields_for(:custom_comments) do |builder| - custom_comment_input(builder, custom_field) - end + render_custom_field(form:, custom_field:) + end + end + + def render_custom_field(form:, custom_field:) + form.fields_for(:custom_field_values) do |builder| + custom_field_input(builder, custom_field) + end + if custom_field.has_comment? + form.fields_for(:custom_comments) do |builder| + custom_comment_input(builder, custom_field) end end end diff --git a/app/forms/users/form/attributes_form.rb b/app/forms/users/form/attributes_form.rb new file mode 100644 index 00000000000..5fc0d814fa6 --- /dev/null +++ b/app/forms/users/form/attributes_form.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 Users + module Form + # Admin flag + the custom-field sections (built-in fields interleaved with + # custom fields per UserCustomFieldSection, honoring attribute_order). + class AttributesForm < ApplicationForm + include CustomFields::CustomFieldRendering + + form do |f| + admin_flag(f) if User.current.admin? + user_sections(f) + end + + def initialize(user:, contract:) + super() + @user = user + @contract = contract + end + + private + + def custom_fields + @custom_fields ||= @user.available_custom_fields + end + + def admin_flag(form) + form.check_box(name: :admin, + label: User.human_attribute_name(:admin), + disabled: @user == User.current) + end + + def user_sections(form) + UserCustomFieldSection.includes(:custom_fields).each do |section| # rubocop:disable Rails/FindEach -- honor default scope ordering + render_section(form, section) + end + end + + def render_section(form, section) + visible_cfs_by_key = section.custom_fields.visible(User.current).index_by(&:column_name) + + form.fieldset_group(title: section_title(section)) do |group| + section.attribute_order.each do |key| + if UserCustomFieldSection::BUILT_IN_ATTRIBUTES.include?(key) + render_built_in(group, key) + elsif (custom_field = visible_cfs_by_key[key]) + render_custom_field(form: group, custom_field:) + end + end + end + end + + def section_title(section) + section.name.presence || I18n.t("settings.user_custom_fields.label_untitled_section") + end + + def render_built_in(group, key) # rubocop:disable Metrics/AbcSize + case key + when "firstname", "lastname", "mail" + group.text_field(name: key.to_sym, + label: User.human_attribute_name(key), + required: true, + disabled: !@contract.writable?(key.to_sym), + input_width: :medium) + when "login" + return if @user.new_record? + + group.text_field(name: :login, + label: User.human_attribute_name(:login), + required: true, + disabled: !@contract.writable?(:login), + input_width: :medium) + when "language" + group.select_list(name: :language, + label: User.human_attribute_name(:language), + include_blank: "--- #{I18n.t(:actionview_instancetag_blank_option)} ---", + input_width: :medium) do |list| + helpers.lang_options_for_select.each { |label, value| list.option(label:, value:) } + end + end + end + end + end +end diff --git a/app/forms/users/form/authentication_source_form.rb b/app/forms/users/form/authentication_source_form.rb new file mode 100644 index 00000000000..8a399d7a63c --- /dev/null +++ b/app/forms/users/form/authentication_source_form.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 Users + module Form + # LDAP authentication source select. For a new record it also renders the + # hidden login field the admin--users controller reveals when an LDAP source + # is selected; for a persisted record the login is already a built-in field, + # so the select is wrapped in a titled "Authentication" fieldset only. + # + # The coordinator decides whether to include this form at all. + class AuthenticationSourceForm < ApplicationForm + form do |f| + if @user.new_record? + ldap_auth_source_select(f) + hidden_login(f) + else + f.fieldset_group(title: I18n.t(:label_authentication)) do |group| + ldap_auth_source_select(group) + end + end + end + + def initialize(user:) + super() + @user = user + end + + private + + def ldap_auth_source_select(target) + target.select_list( + name: :ldap_auth_source_id, + label: User.human_attribute_name(:auth_source), + include_blank: I18n.t(:label_internal), + input_width: :medium, + data: { action: "admin--users#toggleAuthenticationFields" } + ) do |list| + LdapAuthSource.order(:name).each { |source| list.option(label: source.name, value: source.id) } + end + end + + def hidden_login(form) + form.group(hidden: true, data: { "admin--users-target": "authSourceFields" }) do |group| + group.text_field(name: :login, + label: User.human_attribute_name(:login), + required: true, + input_width: :medium) + end + end + end + end +end diff --git a/app/forms/users/form/password_form.rb b/app/forms/users/form/password_form.rb new file mode 100644 index 00000000000..30d56dd0f3b --- /dev/null +++ b/app/forms/users/form/password_form.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 Users + module Form + # The internal password / activation block as a single Primer group. All three + # Stimulus controllers sit on the wrapper: the password-requirements controller + # finds both its passwordInput and requirement targets within it. + # assign_random_password and send_information are not model + # attributes, so they render with an explicit checked: and no hidden companion + # (the controller relies on their absence when unchecked); send_information is + # unscoped (top-level param). Included by the coordinator only for editing an + # internal user as admin while password login is enabled. + class PasswordForm < ApplicationForm + form do |f| + f.group( + hidden: !@user.change_password_allowed?, + data: { + controller: "disable-when-checked password-force-change password-requirements", + "admin--users-target": "passwordFields" + } + ) do |group| + assign_random_password(group) + password_fields(group) unless disable_password_choice? + send_information(group) + force_password_change(group) + end + end + + def initialize(user:, assign_random_password_checked:) + super() + @user = user + @assign_random_password_checked = assign_random_password_checked + end + + private + + def disable_password_choice? + OpenProject::Configuration.disable_password_choice? + end + + def assign_random_password(group) + group.check_box(name: :assign_random_password, + id: "user_assign_random_password", + checked: @assign_random_password_checked, + include_hidden: false, + label: I18n.t("user.assign_random_password"), + data: { + "disable-when-checked-target": "cause", + "password-force-change-target": "assignRandomPassword" + }) + end + + def password_fields(group) + group.text_field(name: :password, + id: "user_password", + type: :password, + required: @user.new_record?, + label: User.human_attribute_name(:password), + caption: helpers.password_complexity_requirements, + input_width: :medium, + data: { + "disable-when-checked-target": "effect", + "password-requirements-target": "passwordInput" + }) + group.text_field(name: :password_confirmation, + id: "user_password_confirmation", + type: :password, + required: @user.new_record?, + label: User.human_attribute_name(:password_confirmation), + input_width: :medium, + data: { "disable-when-checked-target": "effect" }) + end + + def send_information(group) + group.check_box(name: :send_information, + id: "send_information", + scope_name_to_model: false, + scope_id_to_model: false, + checked: false, + include_hidden: false, + label: I18n.t(:label_send_information), + caption: I18n.t("users.send_information_hint"), + data: { "password-force-change-target": "sendInformationCheckbox" }) + end + + def force_password_change(group) + group.check_box(name: :force_password_change, + id: "user_force_password_change", + checked: @user.force_password_change, + label: User.human_attribute_name(:force_password_change), + caption: I18n.t("users.force_password_change_hint"), + data: { "password-force-change-target": "forceChangeCheckbox" }) + end + end + end +end diff --git a/app/forms/users/form/preferences_form.rb b/app/forms/users/form/preferences_form.rb new file mode 100644 index 00000000000..019c787f782 --- /dev/null +++ b/app/forms/users/form/preferences_form.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 Users + module Form + # Preferences section of the administration user form: time zone, color mode + # and keyboard shortcuts. Bound to the user's preference (scope: :pref). + # + # My::LookAndFeelForm could not be reused directly because it renders its own + # submit button and extra fields (comments sorting, contrast); the time zone + # option building mirrors My::TimeZoneForm. + class PreferencesForm < ApplicationForm + form do |f| + f.fieldset_group(title: I18n.t(:label_preferences)) do |group| + group.select_list( + name: :time_zone, + label: UserPreference.human_attribute_name(:time_zone), + include_blank: false, + input_width: :medium + ) do |list| + time_zone_options.each { |label, value| list.option(label:, value:) } + end + + group.select_list( + name: :theme, + label: UserPreference.human_attribute_name(:theme), + caption: UserPreference.human_attribute_name(:mode_guideline), + include_blank: false, + input_width: :medium + ) do |list| + helpers.theme_options_for_select.each { |label, value| list.option(label:, value:) } + end + + group.check_box( + name: :disable_keyboard_shortcuts, + label: UserPreference.human_attribute_name(:disable_keyboard_shortcuts), + caption: helpers.link_translate(:"user_preferences.disable_keyboard_shortcuts_caption", + links: { docs_url: %i[shortcuts] }) + ) + end + end + + private + + def time_zone_options + UserPreferences::UpdateContract + .assignable_time_zones + .group_by { |zone| zone.tzinfo.canonical_zone } + .map { |canonical_zone, zones| time_zone_entry(canonical_zone, zones) } + end + + def time_zone_entry(canonical_zone, zones) + zone_names = zones.map(&:name).join(", ") + offset = ActiveSupport::TimeZone.seconds_to_utc_offset(canonical_zone.base_utc_offset) + + ["(UTC#{offset}) #{zone_names}", canonical_zone.identifier] + end + end + end +end diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 779cda7ff61..7ca94bbc8a4 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -153,4 +153,34 @@ module UsersHelper def can_users_have_auth_source? LdapAuthSource.any? && !OpenProject::Configuration.disable_password_login? end + + # Renders the user form extension hooks inside the (Primer) user form. + # + # - +view_users_primer_form+ receives the Primer form builder and is the API + # plugins should use going forward. + # - +view_users_form+ is deprecated but kept working: it receives a legacy + # TabularFormBuilder rendered inside the same form, so existing plugins keep + # submitting their fields. It is only rendered (and the deprecation logged) + # when a listener is actually registered. + def render_user_form_hooks(user:, form:) + safe_join([ + call_hook(:view_users_primer_form, user:, form:), + legacy_user_form_hook(user:) + ].compact) + end + + private + + def legacy_user_form_hook(user:) + return unless OpenProject::Hook.hook_listeners(:view_users_form).any? + + OpenProject::Deprecation.warn( + "The `view_users_form` hook is deprecated; migrate to `view_users_primer_form` " \ + "which receives a Primer form builder." + ) + + fields_for(:user, user, builder: TabularFormBuilder) do |legacy_form| + call_hook(:view_users_form, user:, form: legacy_form) + end + end end diff --git a/app/views/users/_consent.html.erb b/app/views/users/_consent.html.erb index ee2c08b82c9..4e728216868 100644 --- a/app/views/users/_consent.html.erb +++ b/app/views/users/_consent.html.erb @@ -1,11 +1,12 @@ +<%# locals: (user:) -%>

<%= t("consent.title") %>

<% consent_link = admin_settings_users_path(anchor: "consent_settings") %> <%= render ::Components::OnOffStatusComponent.new( { - is_on: @user.consented_at.present?, - on_text: format_time(@user.consented_at), + is_on: user.consented_at.present?, + on_text: format_time(user.consented_at), on_description: link_translate("consent.user_has_consented", links: { consent_settings: consent_link }), off_text: t(:label_never), off_description: link_translate("consent.not_yet_consented", links: { consent_settings: consent_link }) diff --git a/app/views/users/_form.html.erb b/app/views/users/_form.html.erb deleted file mode 100644 index 7c55671ce62..00000000000 --- a/app/views/users/_form.html.erb +++ /dev/null @@ -1,51 +0,0 @@ -<%#-- copyright -OpenProject is an open source project management software. -Copyright (C) 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. - -++#%> - -<%= error_messages_for @user %> - -
- <% if current_user.admin? %> - <%= render partial: "users/form/admin_flag", locals: { f: f } %> - <% end %> - - <% UserCustomFieldSection.includes(:custom_fields).each do |section| # rubocop:disable Rails/FindEach -- to honor default scope ordering %> - <%= render Users::Form::CustomFieldSectionComponent.new(section:, form: f, contract: @contract, user: @user) %> - <% end %> - <%= call_hook(:view_users_form, user: @user, form: f) %> -
- -<% if Setting.consent_required? %> - <%= render partial: "users/consent" %> -<% end %> - -<%= render partial: "users/form/authentication/form", locals: { f: f } %> - -<% if current_user.admin? %> - <%= render partial: "users/form/preferences", locals: { f: f } %> -<% end %> diff --git a/app/views/users/_general.html.erb b/app/views/users/_general.html.erb index d869adf9db5..02928a5246b 100644 --- a/app/views/users/_general.html.erb +++ b/app/views/users/_general.html.erb @@ -27,32 +27,20 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -
-
- <%= styled_label_tag "FIXME", I18n.t("attributes.status") %> -
- - <%= full_user_status(@user, true) %> - -
-
-
+<%= error_messages_for @user %> -<%= labelled_tabular_form_for @user, - url: { controller: "/users", - action: "update", - tab: nil }, - html: { - method: :put, - autocomplete: "off" - }, - data: { - controller: "admin--users", - turbo: false, - "admin--users-password-auth-selected-value": @user.ldap_auth_source_id.blank? - }, - as: :user do |f| %> - <%= render partial: "users/form", locals: { f: f } %> - - <%= styled_button_tag t(:button_save), class: "-primary -with-icon icon-checkmark" %> -<% end %> +<%= + settings_primer_form_with( + model: @user, + url: { controller: "/users", action: "update", tab: nil }, + method: :put, + html: { autocomplete: "off" }, + data: { + controller: "admin--users", + turbo: false, + "admin--users-password-auth-selected-value": @user.ldap_auth_source_id.blank? + } + ) do |f| + render(Users::FormComponent.new(builder: f, user: @user, contract: @contract)) + end +%> diff --git a/app/views/users/_preferences.html.erb b/app/views/users/_preferences.html.erb deleted file mode 100644 index 02be01dfd76..00000000000 --- a/app/views/users/_preferences.html.erb +++ /dev/null @@ -1,51 +0,0 @@ -<%#-- copyright -OpenProject is an open source project management software. -Copyright (C) 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. - -++#%> -<%= fields_for :pref, @user.pref, builder: TabularFormBuilder, lang: current_language do |pref_fields| %> - <%= render Settings::TimeZoneSettingComponent.new( - "time_zone", - form: pref_fields, - include_blank: false, - container_class: defined?(input_size) ? "-#{input_size}" : "-wide" - ) %> -
- <%= pref_fields.select :theme, theme_options_for_select, container_class: "-middle" %> -
- <%= I18n.t("activerecord.attributes.user_preference.mode_guideline") %> -
-
- -
- <%= pref_fields.check_box :disable_keyboard_shortcuts, - label: I18n.t("activerecord.attributes.user_preference.disable_keyboard_shortcuts") %> - - <%= link_translate(:"user_preferences.disable_keyboard_shortcuts_caption", - links: { docs_url: %i[shortcuts] }) %> - -
-<% end %> diff --git a/app/views/users/_simple_form.html.erb b/app/views/users/_simple_form.html.erb deleted file mode 100644 index 3a46855ecac..00000000000 --- a/app/views/users/_simple_form.html.erb +++ /dev/null @@ -1,40 +0,0 @@ -<%#-- copyright -OpenProject is an open source project management software. -Copyright (C) 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. - -++#%> - -
- <% if current_user.admin? %> - <%= render partial: "users/form/authentication/auth_source", locals: { f: f } %> - <%= render partial: "users/form/admin_flag", locals: { f: f } %> - <% end %> - - <% UserCustomFieldSection.includes(:custom_fields).each do |section| # rubocop:disable Rails/FindEach -- to honor default scope ordering %> - <%= render Users::Form::CustomFieldSectionComponent.new(section:, form: f, contract: @contract, user: @user) %> - <% end %> - <%= call_hook(:view_users_form, user: @user, form: f) %> -
diff --git a/app/views/users/form/_admin_flag.html.erb b/app/views/users/form/_admin_flag.html.erb deleted file mode 100644 index a77037e4366..00000000000 --- a/app/views/users/form/_admin_flag.html.erb +++ /dev/null @@ -1,4 +0,0 @@ -
- <%= f.check_box :admin, - disabled: (@user == User.current) %> -
diff --git a/app/views/users/form/_preferences.html.erb b/app/views/users/form/_preferences.html.erb deleted file mode 100644 index fe723dd53a3..00000000000 --- a/app/views/users/form/_preferences.html.erb +++ /dev/null @@ -1,4 +0,0 @@ -
-

<%= t(:label_preferences) %>

- <%= render partial: "users/preferences", locals: { input_size: :middle } %> -
diff --git a/app/views/users/form/authentication/_auth_source.html.erb b/app/views/users/form/authentication/_auth_source.html.erb deleted file mode 100644 index efdf690ea02..00000000000 --- a/app/views/users/form/authentication/_auth_source.html.erb +++ /dev/null @@ -1,22 +0,0 @@ -<% if can_users_have_auth_source? %> -
- <%= f.collection_select :ldap_auth_source_id, - LdapAuthSource.all, - :id, - :name, - { - label: :"activerecord.attributes.user.auth_source", - container_class: "-middle", - include_blank: t(:label_internal) - }, - data: { - action: "admin--users#toggleAuthenticationFields" - } %> -
- - -<% end %> diff --git a/app/views/users/form/authentication/_external.html.erb b/app/views/users/form/authentication/_external.html.erb index c14c24105a5..749daf79d81 100644 --- a/app/views/users/form/authentication/_external.html.erb +++ b/app/views/users/form/authentication/_external.html.erb @@ -1,19 +1,16 @@ -
- <%= styled_label_tag nil, I18n.t("user.authentication_provider") %> -
- <%= @user.human_authentication_provider %> -
-
-
- <%= I18n.t("user.authentication_settings_disabled_due_to_external_authentication") %> -
+<%# locals: (user:) -%> +<%= render(Primer::Box.new(mb: 3)) do %> + <%= render(Primer::Beta::Text.new(tag: :div, font_weight: :bold)) { I18n.t("user.authentication_provider") } %> + <%= render(Primer::Beta::Text.new(tag: :div)) { user.human_authentication_provider } %> + <%= render(Primer::Beta::Text.new(tag: :div, color: :muted, font_size: :small)) do %> + <%= I18n.t("user.authentication_settings_disabled_due_to_external_authentication") %> + <% end %> +<% end %> -
- <%= styled_label_tag nil, User.human_attribute_name(:identity_url) %> -
- <%= @user.identity_url.split(":", 2).last %> -
-
-
- <%= I18n.t("user.identity_url_text") %> -
+<%= render(Primer::Box.new) do %> + <%= render(Primer::Beta::Text.new(tag: :div, font_weight: :bold)) { User.human_attribute_name(:identity_url) } %> + <%= render(Primer::Beta::Text.new(tag: :div)) { user.identity_url.split(":", 2).last } %> + <%= render(Primer::Beta::Text.new(tag: :div, color: :muted, font_size: :small)) do %> + <%= I18n.t("user.identity_url_text") %> + <% end %> +<% end %> diff --git a/app/views/users/form/authentication/_form.html.erb b/app/views/users/form/authentication/_form.html.erb deleted file mode 100644 index 1bec280b283..00000000000 --- a/app/views/users/form/authentication/_form.html.erb +++ /dev/null @@ -1,15 +0,0 @@ -
- <% if current_user.admin? || can_users_have_auth_source? || @user.uses_external_authentication? %> -

<%= t(:label_authentication) %>

- <% end %> - - <% if @user.uses_external_authentication? %> - <%= render partial: "users/form/authentication/external", locals: { f: f } %> - <% else %> - <%= render partial: "users/form/authentication/auth_source", locals: { f: f } %> - - <% if current_user.admin? %> - <%= render partial: "users/form/authentication/internal", locals: { f: f } %> - <% end %> - <% end %> -
diff --git a/app/views/users/form/authentication/_internal.html.erb b/app/views/users/form/authentication/_internal.html.erb deleted file mode 100644 index 1b7c7f220e0..00000000000 --- a/app/views/users/form/authentication/_internal.html.erb +++ /dev/null @@ -1,12 +0,0 @@ -<% if OpenProject::Configuration.disable_password_login? %> -
-
- <%= styled_label_tag nil, I18n.t(:warning) %> -
- <%= I18n.t "user.no_login" %> -
-
-
-<% else %> - <%= render partial: "users/form/authentication/internal_password", locals: { f: f } %> -<% end %> diff --git a/app/views/users/form/authentication/_internal_password.html.erb b/app/views/users/form/authentication/_internal_password.html.erb deleted file mode 100644 index 405a627d9fc..00000000000 --- a/app/views/users/form/authentication/_internal_password.html.erb +++ /dev/null @@ -1,71 +0,0 @@ -<%= content_tag :div, - data: { - controller: "disable-when-checked password-force-change", - "admin--users-target": "passwordFields" - }, - hidden: !@user.change_password_allowed? do %> - <% assign_random_password_enabled = params[:user] && - params[:user][:assign_random_password] %> -
- <%= styled_label_tag "user_assign_random_password", - I18n.t("user.assign_random_password") %> -
- <%= styled_check_box_tag "user[assign_random_password]", - "1", - assign_random_password_enabled, - data: { - "disable-when-checked-target": "cause", - "password-force-change-target": "assignRandomPassword" - } %> -
-
- - <% unless OpenProject::Configuration.disable_password_choice? %> -
- <%= f.password_field :password, - required: @user.new_record?, - disabled: assign_random_password_enabled, - data: { - "disable-when-checked-target": "effect", - "password-requirements-target": "passwordInput" - }, - container_class: "-middle" %> -
- <%= password_complexity_requirements %> -
-
-
- <%= f.password_field :password_confirmation, - required: @user.new_record?, - disabled: assign_random_password_enabled, - data: { - "disable-when-checked-target": "effect" - }, - container_class: "-middle" %> -
- <% end %> - -
- <%= styled_label_tag "send_information", t(:label_send_information) %> -
- <%= styled_check_box_tag( - "send_information", - "1", - false, - data: { "password-force-change-target": "sendInformationCheckbox" } - ) %> -
-
- <%= t("users.send_information_hint") %> -
-
- -
- <%= f.check_box :force_password_change, - disabled: assign_random_password_enabled, - data: { "password-force-change-target": "forceChangeCheckbox" } %> -
- <%= t("users.force_password_change_hint") %> -
-
-<% end %> diff --git a/app/views/users/form/built_in_fields/_firstname.html.erb b/app/views/users/form/built_in_fields/_firstname.html.erb deleted file mode 100644 index 623344bcd62..00000000000 --- a/app/views/users/form/built_in_fields/_firstname.html.erb +++ /dev/null @@ -1,7 +0,0 @@ -<%# locals: (f:, contract:, user:) -%> -
- <%= f.text_field :firstname, - required: true, - disabled: !contract.writable?(:firstname), - container_class: "-middle" %> -
diff --git a/app/views/users/form/built_in_fields/_language.html.erb b/app/views/users/form/built_in_fields/_language.html.erb deleted file mode 100644 index ed8562925f0..00000000000 --- a/app/views/users/form/built_in_fields/_language.html.erb +++ /dev/null @@ -1,7 +0,0 @@ -<%# locals: (f:, contract:, user:) -%> -
- <%= f.select :language, - lang_options_for_select, - prompt: "--- #{t(:actionview_instancetag_blank_option)} ---", - container_class: "-middle" %> -
diff --git a/app/views/users/form/built_in_fields/_lastname.html.erb b/app/views/users/form/built_in_fields/_lastname.html.erb deleted file mode 100644 index e28ad9940a2..00000000000 --- a/app/views/users/form/built_in_fields/_lastname.html.erb +++ /dev/null @@ -1,7 +0,0 @@ -<%# locals: (f:, contract:, user:) -%> -
- <%= f.text_field :lastname, - required: true, - disabled: !contract.writable?(:lastname), - container_class: "-middle" %> -
diff --git a/app/views/users/form/built_in_fields/_login.html.erb b/app/views/users/form/built_in_fields/_login.html.erb deleted file mode 100644 index 9630997392b..00000000000 --- a/app/views/users/form/built_in_fields/_login.html.erb +++ /dev/null @@ -1,10 +0,0 @@ -<%# locals: (f:, contract:, user:) -%> -<% unless user.new_record? %> -
- <%= f.text_field :login, - required: true, - disabled: !contract.writable?(:login), - size: 25, - container_class: "-middle" %> -
-<% end %> diff --git a/app/views/users/form/built_in_fields/_mail.html.erb b/app/views/users/form/built_in_fields/_mail.html.erb deleted file mode 100644 index 1d0245ce522..00000000000 --- a/app/views/users/form/built_in_fields/_mail.html.erb +++ /dev/null @@ -1,7 +0,0 @@ -<%# locals: (f:, contract:, user:) -%> -
- <%= f.text_field :mail, - required: true, - disabled: !contract.writable?(:mail), - container_class: "-middle" %> -
diff --git a/app/views/users/new.html.erb b/app/views/users/new.html.erb index 35acaba0f5b..e03cb16a145 100644 --- a/app/views/users/new.html.erb +++ b/app/views/users/new.html.erb @@ -43,19 +43,17 @@ See COPYRIGHT and LICENSE files for more details. <%= error_messages_for @user %> -<%= labelled_tabular_form_for @user, - url: { action: "create" }, - html: { class: nil, autocomplete: "off" }, - data: { - controller: "admin--users", - turbo: false, - "admin--users-password-auth-selected-value": @user.ldap_auth_source_id.blank? - }, - as: :user do |f| %> - <%= render partial: "simple_form", locals: { f: f, auth_sources: @ldap_auth_sources, user: @user } %> - -

- <%= styled_button_tag t(:button_create), class: "-primary -with-icon icon-checkmark" %> - <%= styled_button_tag t(:button_create_and_continue), name: "continue", class: "-primary -with-icon icon-checkmark" %> -

-<% end %> +<%= + settings_primer_form_with( + model: @user, + url: { action: "create" }, + html: { autocomplete: "off" }, + data: { + controller: "admin--users", + turbo: false, + "admin--users-password-auth-selected-value": @user.ldap_auth_source_id.blank? + } + ) do |f| + render(Users::FormComponent.new(builder: f, user: @user, contract: @contract)) + end +%> diff --git a/spec/components/users/form_component_spec.rb b/spec/components/users/form_component_spec.rb new file mode 100644 index 00000000000..92569b9c6f0 --- /dev/null +++ b/spec/components/users/form_component_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Users::FormComponent, type: :component do + let(:current_user) { build_stubbed(:admin) } + let(:contract) { instance_double(Users::UpdateContract, writable?: true) } + + before do + User.current = current_user + create(:user_custom_field_section, attribute_order: UserCustomFieldSection::BUILT_IN_ATTRIBUTES) + end + + def render_component(user:) + render_in_view_context(user, contract) do |form_user, form_contract| + primer_form_with(model: form_user, url: "/users") do |form| + render(Users::FormComponent.new(builder: form, user: form_user, contract: form_contract)) + end + end + end + + context "for a new user" do + it "renders the attributes and the create buttons, but no password block" do + render_component(user: User.new) + + expect(page).to have_field("user[firstname]") + expect(page).to have_button(I18n.t(:button_create)) + expect(page).to have_button(I18n.t(:button_create_and_continue)) + expect(page).to have_no_css("[data-admin--users-target='passwordFields']", visible: :all) + end + end + + context "for a persisted internal user edited by an admin" do + let(:user) { create(:user) } + + it "renders status, the password block, preferences and a Save button" do + render_component(user:) + + expect(page).to have_css("[data-admin--users-target='passwordFields']", visible: :all) + expect(page).to have_select("pref[time_zone]") + expect(page).to have_button(I18n.t(:button_save)) + end + + it "renders the consent status when consent is required" do + allow(Setting).to receive(:consent_required?).and_return(true) + + render_component(user:) + + expect(page).to have_text(I18n.t("consent.title")) + end + end + + context "for a persisted external-auth user" do + let(:user) { create(:user, identity_url: "saml:user-id") } + + before { allow(user).to receive(:uses_external_authentication?).and_return(true) } + + it "renders the read-only authentication provider instead of the password block" do + render_component(user:) + + expect(page).to have_text(I18n.t("user.authentication_provider")) + expect(page).to have_no_css("[data-admin--users-target='passwordFields']", visible: :all) + end + end +end diff --git a/spec/features/users/edit_users_spec.rb b/spec/features/users/edit_users_spec.rb index 23c65f6b878..15355fb483b 100644 --- a/spec/features/users/edit_users_spec.rb +++ b/spec/features/users/edit_users_spec.rb @@ -59,7 +59,7 @@ RSpec.describe "edit users", :js do before { visit edit_user_path(user) } it "saves a custom field value" do - within "fieldset", text: "Professional info".upcase do + within "fieldset", text: "Professional info" do fill_in "Job title", with: "Software Engineer" fill_in "Internal code", with: "SE" end diff --git a/spec/features/users/password_change_spec.rb b/spec/features/users/password_change_spec.rb index 035094ad054..a5f046612a4 100644 --- a/spec/features/users/password_change_spec.rb +++ b/spec/features/users/password_change_spec.rb @@ -143,19 +143,19 @@ RSpec.describe "random password generation", :js do # And I try to set my new password to "adminADMIN" fill_in "user_password", with: "adminADMIN" fill_in "user_password_confirmation", with: "adminADMIN" - scroll_to_and_click(find(".button", text: "Save")) + scroll_to_and_click(find_button("Save")) expect_flash(type: :error, message: "Password Must include characters of the following types") # Has numeric but still missing special fill_in "user_password", with: "adminADMIN123" fill_in "user_password_confirmation", with: "adminADMIN123" - scroll_to_and_click(find(".button", text: "Save")) + scroll_to_and_click(find_button("Save")) expect_flash(type: :error, message: "Password Must include characters of the following types") # All classes fill_in "user_password", with: "adminADMIN1!" fill_in "user_password_confirmation", with: "adminADMIN1!" - scroll_to_and_click(find(".button", text: "Save")) + scroll_to_and_click(find_button("Save")) expect_flash(message: I18n.t(:notice_successful_update)) end end diff --git a/spec/forms/custom_fields/custom_field_rendering_spec.rb b/spec/forms/custom_fields/custom_field_rendering_spec.rb index e87c717d791..c3f9fdd8d30 100644 --- a/spec/forms/custom_fields/custom_field_rendering_spec.rb +++ b/spec/forms/custom_fields/custom_field_rendering_spec.rb @@ -204,4 +204,26 @@ RSpec.describe CustomFields::CustomFieldRendering do end end end + + describe "#render_custom_field" do + let(:values_builder) { instance_double(ActionView::Helpers::FormBuilder) } + let(:custom_field) { build(:custom_field, :string) } + + before do + allow(builder).to receive(:fields_for).with(:custom_field_values).and_yield(values_builder) + allow(form_instance).to receive(:additional_custom_field_input_arguments).and_return({}) + end + + it "renders a single custom field input" do + allow(CustomFields::Inputs::String).to receive(:new) + + form_instance.render_custom_field(form: builder, custom_field:) + + expect(CustomFields::Inputs::String).to have_received(:new).with( + values_builder, + custom_field:, + object: model + ) + end + end end diff --git a/app/components/users/form/custom_field_field_component.rb b/spec/forms/users/form/attributes_form_spec.rb similarity index 54% rename from app/components/users/form/custom_field_field_component.rb rename to spec/forms/users/form/attributes_form_spec.rb index 2279a5904e4..f862cf759ef 100644 --- a/app/components/users/form/custom_field_field_component.rb +++ b/spec/forms/users/form/attributes_form_spec.rb @@ -28,41 +28,38 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module Users - module Form - class CustomFieldFieldComponent < ApplicationComponent - include ApplicationHelper +require "spec_helper" - def initialize(custom_field:, form:) - super() - @custom_field = custom_field - @form = form - end +RSpec.describe Users::Form::AttributesForm, type: :forms do + before do + User.current = current_user + create(:user_custom_field_section, attribute_order: UserCustomFieldSection::BUILT_IN_ATTRIBUTES) + end - def field_options - { - custom_value: @form.object.custom_value_for(@custom_field), - custom_field: @custom_field - } - end + include_context "with rendered form" - def css_classes - ["form--field", @custom_field.attribute_name, required_class].compact - end + let(:current_user) { build_stubbed(:admin) } + let(:contract) { instance_double(Users::UpdateContract, writable?: true) } + let(:params) { { user: model, contract: } } - def container_class - case @custom_field.field_format - when "text" then "-xxwide" - when "date" then "-xslim" - else "-middle" - end - end + context "for a persisted user" do + let(:model) { build_stubbed(:user) } - private + it "renders the built-in fields, login and the admin flag" do + expect(page).to have_field("user[firstname]") + expect(page).to have_field("user[lastname]") + expect(page).to have_field("user[mail]") + expect(page).to have_select("user[language]") + expect(page).to have_field("user[login]") + expect(page).to have_field("user[admin]") + end + end - def required_class - "-required" if @custom_field.is_required? && !@custom_field.boolean? - end + context "for a new user" do + let(:model) { User.new } + + it "omits the login field" do + expect(page).to have_no_field("user[login]") end end end diff --git a/spec/forms/users/form/authentication_source_form_spec.rb b/spec/forms/users/form/authentication_source_form_spec.rb new file mode 100644 index 00000000000..734075743ba --- /dev/null +++ b/spec/forms/users/form/authentication_source_form_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Users::Form::AuthenticationSourceForm, type: :forms do + before do + User.current = build_stubbed(:admin) + create(:ldap_auth_source) + end + + include_context "with rendered form" + + let(:params) { { user: model } } + + context "for a new user" do + let(:model) { User.new } + + it "renders the auth source select with the toggle action and a hidden login group" do + expect(page).to have_select("user[ldap_auth_source_id]") + expect(page).to have_css("[data-action~='admin--users#toggleAuthenticationFields']") + expect(page).to have_css("[data-admin--users-target='authSourceFields'][hidden]", visible: :all) + expect(page).to have_field("user[login]", visible: :all) + end + end + + context "for a persisted user" do + let(:model) { build_stubbed(:user) } + + it "renders the select inside a titled Authentication fieldset and no hidden login" do + expect(page).to have_select("user[ldap_auth_source_id]") + expect(page).to have_css("fieldset", text: /#{I18n.t(:label_authentication)}/i) + expect(page).to have_no_css("[data-admin--users-target='authSourceFields']", visible: :all) + end + end +end diff --git a/spec/forms/users/form/password_form_spec.rb b/spec/forms/users/form/password_form_spec.rb new file mode 100644 index 00000000000..f79435c0044 --- /dev/null +++ b/spec/forms/users/form/password_form_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Users::Form::PasswordForm, type: :forms do + include_context "with rendered form" + + let(:model) { build_stubbed(:user) } + let(:params) { { user: model, assign_random_password_checked: false } } + + before { allow(model).to receive(:change_password_allowed?).and_return(true) } + + it "renders one wrapper carrying the three controllers and the passwordFields target" do + expect(page).to have_css( + "[data-controller~='disable-when-checked'][data-controller~='password-force-change']" \ + "[data-controller~='password-requirements'][data-admin--users-target='passwordFields']", + visible: :all + ) + end + + it "renders the password fields with their ids and password type" do + expect(page).to have_field("user[password]", type: "password", visible: :all) + expect(page).to have_field("user[password_confirmation]", type: "password", visible: :all) + expect(page).to have_css("#user_password", visible: :all) + end + + it "renders the unscoped send_information checkbox and the scoped flags" do + expect(page).to have_field("send_information", visible: :all) + expect(page).to have_css("#send_information", visible: :all) + expect(page).to have_field("user[assign_random_password]", visible: :all) + expect(page).to have_field("user[force_password_change]", visible: :all) + end +end diff --git a/app/components/users/form/custom_field_section_component.rb b/spec/forms/users/form/preferences_form_spec.rb similarity index 61% rename from app/components/users/form/custom_field_section_component.rb rename to spec/forms/users/form/preferences_form_spec.rb index 9ab2bf9da8d..3aabc66cc11 100644 --- a/app/components/users/form/custom_field_section_component.rb +++ b/spec/forms/users/form/preferences_form_spec.rb @@ -28,35 +28,16 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module Users - module Form - class CustomFieldSectionComponent < ApplicationComponent - def initialize(section:, form:, contract:, user:) - super() - @section = section - @form = form - @contract = contract - @user = user - @visible_cfs_by_key = visible_cfs_by_key(section) - end +require "spec_helper" - def title - @section.name.presence || I18n.t("settings.user_custom_fields.label_untitled_section") - end +RSpec.describe Users::Form::PreferencesForm, type: :forms do + include_context "with rendered form" - def built_in?(key) - UserCustomFieldSection::BUILT_IN_ATTRIBUTES.include?(key) - end + let(:model) { build_stubbed(:user_preference) } - def visible_custom_field(key) - @visible_cfs_by_key[key] - end - - private - - def visible_cfs_by_key(section) - section.custom_fields.visible(User.current).index_by(&:column_name) - end - end + it "renders the time zone, color mode and keyboard shortcuts fields" do + expect(page).to have_select("Time zone") + expect(page).to have_select("Color mode") + expect(page).to have_field("Disable keyboard shortcuts") end end diff --git a/spec/helpers/users_helper_form_hooks_spec.rb b/spec/helpers/users_helper_form_hooks_spec.rb new file mode 100644 index 00000000000..046a3517e09 --- /dev/null +++ b/spec/helpers/users_helper_form_hooks_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe UsersHelper do + describe "#render_user_form_hooks" do + let(:form) { instance_double(Primer::Forms::Builder) } + let(:user) { build_stubbed(:user) } + + before do + allow(helper).to receive(:call_hook).and_return("".html_safe) + allow(OpenProject::Deprecation).to receive(:warn) + allow(helper).to receive(:fields_for).and_yield(instance_double(TabularFormBuilder)) + end + + it "always calls the new Primer hook" do + allow(OpenProject::Hook).to receive(:hook_listeners).with(:view_users_form).and_return([]) + + helper.render_user_form_hooks(user:, form:) + + expect(helper).to have_received(:call_hook).with(:view_users_primer_form, hash_including(form:)) + end + + context "when no legacy listener is registered" do + before { allow(OpenProject::Hook).to receive(:hook_listeners).with(:view_users_form).and_return([]) } + + it "does not warn and does not render the legacy hook" do + helper.render_user_form_hooks(user:, form:) + + expect(OpenProject::Deprecation).not_to have_received(:warn) + expect(helper).not_to have_received(:fields_for) + end + end + + context "when a legacy listener is registered" do + before { allow(OpenProject::Hook).to receive(:hook_listeners).with(:view_users_form).and_return([instance_double(Object)]) } + + it "logs a deprecation and renders the legacy hook" do + helper.render_user_form_hooks(user:, form:) + + expect(OpenProject::Deprecation).to have_received(:warn) + expect(helper).to have_received(:call_hook).with(:view_users_form, hash_including(:form)) + end + end + end +end diff --git a/spec/views/users/edit.html.erb_spec.rb b/spec/views/users/edit.html.erb_spec.rb index 1b64177c74b..7e8f8df16e0 100644 --- a/spec/views/users/edit.html.erb_spec.rb +++ b/spec/views/users/edit.html.erb_spec.rb @@ -43,6 +43,7 @@ RSpec.describe "users/edit" do assign(:auth_sources, []) assign(:contract, Users::UpdateContract.new(user, current_user)) + User.current = current_user without_partial_double_verification do allow(view).to receive(:current_user).and_return(current_user) end @@ -105,7 +106,7 @@ RSpec.describe "users/edit" do end context "with password-based login" do - let(:user) { build(:user, id: 42) } + let(:user) { build_stubbed(:user) } context "with password login disabled" do before do