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 @@ -
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:) -%>- <%= 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