From 535e65b6515636483dfbd60b0dac6cc7dd2b7d63 Mon Sep 17 00:00:00 2001 From: stephanierousset <61418966+Stef-Rousset@users.noreply.github.com> Date: Thu, 28 May 2026 15:33:22 +0200 Subject: [PATCH 1/7] fix: sign in display after clicking on menu btn in mobile (#993) --- app/packs/src/decidim/global_adjustments.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/packs/src/decidim/global_adjustments.js b/app/packs/src/decidim/global_adjustments.js index b50f9b129d..5a5bc5d8ea 100644 --- a/app/packs/src/decidim/global_adjustments.js +++ b/app/packs/src/decidim/global_adjustments.js @@ -71,11 +71,21 @@ document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () { const signIn = document.querySelector('.main-bar a[href^="/users/sign_in"]') const menuBarMobile = document.querySelector('header .main-bar__menu-mobile'); - const menuBarMobileLink = document.querySelector('header .main-bar__links-mobile__login') + const menuBarMobileLink = document.querySelector('header .main-bar__links-mobile__login'); + const button = document.querySelector('#main-dropdown-summary-mobile'); if (window.innerWidth <= 600 && signIn && screen.orientation.type === "portrait-primary"){ menuBarMobile.style.flexDirection = "column-reverse"; if (menuBarMobileLink) menuBarMobileLink.style.marginBottom = "1rem"; + + // hide or show signin when the dropdown-menu is clicked + button.addEventListener('click', () => { + if (menuBarMobile.style.flexDirection == "column-reverse") { + menuBarMobile.style.flexDirection = "row-reverse"; + } else { + menuBarMobile.style.flexDirection = "column-reverse"; + } + }) } screen.orientation.addEventListener("change", () => { if (screen.orientation.type === "landscape-primary"){ From 5a7749c159ae455bedf49a7c252a95e1ef726764 Mon Sep 17 00:00:00 2001 From: AyakorK Date: Mon, 15 Jun 2026 11:59:41 +0200 Subject: [PATCH 2/7] backport: Add initiatives jobs + add specs --- .../check_published_initiatives.rb | 13 ++++ .../check_validating_initiatives.rb | 13 ++++ .../notify_progress_initiatives.rb | 13 ++++ config/sidekiq.yml | 12 +++ .../check_published_initatives_job_spec.rb | 34 ++++++++ .../check_validating_initatives_job_spec.rb | 34 ++++++++ .../notify_progress_initatives_job_spec.rb | 34 ++++++++ spec/tasks/decidim_initiatives_tasks_spec.rb | 78 +++++++++++++++++++ 8 files changed, 231 insertions(+) create mode 100644 app/jobs/decidim/initiatives/check_published_initiatives.rb create mode 100644 app/jobs/decidim/initiatives/check_validating_initiatives.rb create mode 100644 app/jobs/decidim/initiatives/notify_progress_initiatives.rb create mode 100644 spec/jobs/check_published_initatives_job_spec.rb create mode 100644 spec/jobs/check_validating_initatives_job_spec.rb create mode 100644 spec/jobs/notify_progress_initatives_job_spec.rb create mode 100644 spec/tasks/decidim_initiatives_tasks_spec.rb diff --git a/app/jobs/decidim/initiatives/check_published_initiatives.rb b/app/jobs/decidim/initiatives/check_published_initiatives.rb new file mode 100644 index 0000000000..1a3d95013f --- /dev/null +++ b/app/jobs/decidim/initiatives/check_published_initiatives.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Decidim + module Initiatives + class CheckPublishedInitiatives < ApplicationJob + queue_as :initiatives + + def perform + system "rake decidim_initiatives:check_published" + end + end + end +end diff --git a/app/jobs/decidim/initiatives/check_validating_initiatives.rb b/app/jobs/decidim/initiatives/check_validating_initiatives.rb new file mode 100644 index 0000000000..d527b9c7f2 --- /dev/null +++ b/app/jobs/decidim/initiatives/check_validating_initiatives.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Decidim + module Initiatives + class CheckValidatingInitiatives < ApplicationJob + queue_as :initiatives + + def perform + system "rake decidim_initiatives:check_validating" + end + end + end +end diff --git a/app/jobs/decidim/initiatives/notify_progress_initiatives.rb b/app/jobs/decidim/initiatives/notify_progress_initiatives.rb new file mode 100644 index 0000000000..38b4b8ae69 --- /dev/null +++ b/app/jobs/decidim/initiatives/notify_progress_initiatives.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Decidim + module Initiatives + class NotifyProgressInitiatives < ApplicationJob + queue_as :initiatives + + def perform + system "rake decidim_initiatives:notify_progress" + end + end + end +end diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 4b5298cd4c..bce1d3cf5c 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -37,6 +37,18 @@ cron: "*/15 * * * *" class: Decidim::ParticipatoryProcesses::ChangeActiveStepJob queue: scheduled + CheckPublishedInitiatives: + cron: '0 1 * * *' + class: Decidim::Initiatives::CheckPublishedInitiatives + queue: initiatives + CheckValidatingInitiatives: + cron: '0 1 * * *' + class: Decidim::Initiatives::CheckValidatingInitiatives + queue: initiatives + NotifyProgressInitiatives: + cron: '0 1 * * *' + class: Decidim::Initiatives::NotifyProgressInitiatives + queue: initiatives # Decidim-AI Spam Digest Jobs AiSpamDigestDaily: diff --git a/spec/jobs/check_published_initatives_job_spec.rb b/spec/jobs/check_published_initatives_job_spec.rb new file mode 100644 index 0000000000..103799bd27 --- /dev/null +++ b/spec/jobs/check_published_initatives_job_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Initiatives + describe CheckPublishedInitiatives do + subject { described_class } + + describe "queue" do + it "is queued to initiatives" do + expect(subject.queue_name).to eq "initiatives" + end + end + + describe "#perform" do + it "enqueues a job with perform_later" do + expect do + described_class.perform_later + end.to have_enqueued_job(described_class) + end + + it "runs the check_published rake task" do + job = described_class.new + allow(job).to receive(:system) + + job.perform + + expect(job).to have_received(:system).with("rake decidim_initiatives:check_published") + end + end + end + end +end diff --git a/spec/jobs/check_validating_initatives_job_spec.rb b/spec/jobs/check_validating_initatives_job_spec.rb new file mode 100644 index 0000000000..c83b32e74b --- /dev/null +++ b/spec/jobs/check_validating_initatives_job_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Initiatives + describe CheckValidatingInitiatives do + subject { described_class } + + describe "queue" do + it "is queued to initiatives" do + expect(subject.queue_name).to eq "initiatives" + end + end + + describe "#perform" do + it "enqueues a job with perform_later" do + expect do + described_class.perform_later + end.to have_enqueued_job(described_class) + end + + it "runs the check_validating rake task" do + job = described_class.new + allow(job).to receive(:system) + + job.perform + + expect(job).to have_received(:system).with("rake decidim_initiatives:check_validating") + end + end + end + end +end diff --git a/spec/jobs/notify_progress_initatives_job_spec.rb b/spec/jobs/notify_progress_initatives_job_spec.rb new file mode 100644 index 0000000000..b701ce077f --- /dev/null +++ b/spec/jobs/notify_progress_initatives_job_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Initiatives + describe NotifyProgressInitiatives do + subject { described_class } + + describe "queue" do + it "is queued to initiatives" do + expect(subject.queue_name).to eq "initiatives" + end + end + + describe "#perform" do + it "enqueues a job with perform_later" do + expect do + described_class.perform_later + end.to have_enqueued_job(described_class) + end + + it "runs the notify_progress rake task" do + job = described_class.new + allow(job).to receive(:system) + + job.perform + + expect(job).to have_received(:system).with("rake decidim_initiatives:notify_progress") + end + end + end + end +end diff --git a/spec/tasks/decidim_initiatives_tasks_spec.rb b/spec/tasks/decidim_initiatives_tasks_spec.rb new file mode 100644 index 0000000000..5220e4003b --- /dev/null +++ b/spec/tasks/decidim_initiatives_tasks_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "decidim_initiatives:check_published", type: :task do + let(:organization) { create(:organization) } + let(:initiative_type) { create(:initiatives_type, organization:) } + let(:initiative_type_scope) { create(:initiatives_type_scope, type: initiative_type) } + + context "when the signing period has ended" do + context "when the initiative has NOT reached the signature threshold" do + let!(:initiative) do + create( + :initiative, + :published, + :rejectable, + organization:, + scoped_type: initiative_type_scope, + signature_end_date: 1.day.ago + ) + end + + it "moves the initiative to rejected state" do + expect { task.execute }.to change { initiative.reload.state }.from("published").to("rejected") + end + end + + context "when the initiative HAS reached the signature threshold" do + let!(:initiative) do + create( + :initiative, + :published, + :acceptable, + organization:, + scoped_type: initiative_type_scope, + signature_end_date: 1.day.ago + ) + end + + it "moves the initiative to accepted state" do + expect { task.execute }.to change { initiative.reload.state }.from("published").to("accepted") + end + end + end + + context "when the signing period is still active" do + let!(:initiative) do + create( + :initiative, + :published, + :rejectable, + organization:, + scoped_type: initiative_type_scope, + signature_end_date: 1.day.from_now + ) + end + + it "does not change the initiative state" do + expect { task.execute }.not_to(change { initiative.reload.state }) + end + end + + context "when the initiative is not in published state" do + let!(:initiative) do + create( + :initiative, + :created, + organization:, + scoped_type: initiative_type_scope, + signature_end_date: 1.day.ago + ) + end + + it "does not change the initiative state" do + expect { task.execute }.not_to(change { initiative.reload.state }) + end + end +end From 42d915e67c021f811970bf9e7548364e38fd52fe Mon Sep 17 00:00:00 2001 From: AyakorK Date: Tue, 30 Jun 2026 15:43:37 +0200 Subject: [PATCH 3/7] fix: Display correctly an error when trying to update with invalid image --- config/application.rb | 5 ++ config/locales/en.yml | 3 + config/locales/fr.yml | 3 + .../update_content_block_extends.rb | 26 ++++++++ ..._page_content_blocks_controller_extends.rb | 27 ++++++++ ..._page_content_blocks_controller_extends.rb | 27 ++++++++ .../admin/content_block_form_extends.rb | 42 ++++++++++++ .../content_block_attachment_extends.rb | 19 ++++++ .../update_content_block_extends_spec.rb | 45 +++++++++++++ .../admin/content_block_form_extends_spec.rb | 46 +++++++++++++ .../content_block_attachment_extends_spec.rb | 35 ++++++++++ .../admin/landing_page_content_blocks_spec.rb | 66 +++++++++++++++++++ .../admin/landing_page_content_blocks_spec.rb | 66 +++++++++++++++++++ ...anages_landing_page_content_blocks_spec.rb | 49 ++++++++++++++ 14 files changed, 459 insertions(+) create mode 100644 lib/extends/commands/decidim/admin/content_blocks/update_content_block_extends.rb create mode 100644 lib/extends/controllers/decidim/assemblies/admin/assembly_landing_page_content_blocks_controller_extends.rb create mode 100644 lib/extends/controllers/decidim/participatory_processes/admin/participatory_process_landing_page_content_blocks_controller_extends.rb create mode 100644 lib/extends/forms/decidim/admin/content_block_form_extends.rb create mode 100644 lib/extends/models/decidim/content_block_attachment_extends.rb create mode 100644 spec/commands/decidim/admin/content_blocks/update_content_block_extends_spec.rb create mode 100644 spec/forms/decidim/admin/content_block_form_extends_spec.rb create mode 100644 spec/models/decidim/content_block_attachment_extends_spec.rb create mode 100644 spec/requests/decidim/assemblies/admin/landing_page_content_blocks_spec.rb create mode 100644 spec/requests/decidim/participatory_processes/admin/landing_page_content_blocks_spec.rb create mode 100644 spec/system/decidim/assemblies/admin/admin_manages_landing_page_content_blocks_spec.rb diff --git a/config/application.rb b/config/application.rb index 46188ef83d..085e00068d 100644 --- a/config/application.rb +++ b/config/application.rb @@ -22,6 +22,7 @@ class Application < Rails::Application require "extends/commands/decidim/participatory_processes/admin/copy_participatory_process_extends" require "extends/commands/decidim/create_omniauth_registration_extends" require "extends/commands/decidim/forms/admin/update_questionnaire_extends" + require "extends/commands/decidim/admin/content_blocks/update_content_block_extends" # forms require "extends/forms/decidim/assemblies/admin/assembly_copy_form_extends" require "extends/forms/decidim/participatory_processes/admin/participatory_process_copy_form_extends" @@ -31,6 +32,7 @@ class Application < Rails::Application require "extends/forms/decidim/omniauth_registration_form_extends" require "extends/forms/decidim/account_form_extends" require "extends/forms/decidim/editor_image_form_extends" + require "extends/forms/decidim/admin/content_block_form_extends" # controllers require "extends/controllers/decidim/admin/scopes_controller_extends" require "extends/controllers/decidim/scopes_controller_extends" @@ -43,6 +45,8 @@ class Application < Rails::Application require "extends/controllers/decidim/budgets/projects_controller_extends" require "extends/controllers/decidim/proposals/admin/proposal_states_controller_extends" require "extends/controllers/decidim/proposals/proposals_controller_extends" + require "extends/controllers/decidim/assemblies/admin/assembly_landing_page_content_blocks_controller_extends" + require "extends/controllers/decidim/participatory_processes/admin/participatory_process_landing_page_content_blocks_controller_extends" # helpers require "extends/helpers/decidim/check_boxes_tree_helper_extends" require "extends/helpers/decidim/omniauth_helper_extends" @@ -57,6 +61,7 @@ class Application < Rails::Application require "extends/models/decidim/accountability/result_extends" require "extends/models/decidim/proposals/proposal_state_extends" require "extends/models/decidim/searchable_author_extends" + require "extends/models/decidim/content_block_attachment_extends" # permissions require "extends/permissions/initiatives/permissions_extends" # presenters diff --git a/config/locales/en.yml b/config/locales/en.yml index 83e42934bc..714a9fb81a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -34,6 +34,9 @@ en: attachments: form: send_notification_to_followers: Send a notification to all the people following the consultation who have agreed to receive email notifications + content_blocks: + update: + error: There was an error updating the content block models: assembly: fields: diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 7f149e9b1b..e7ef9c42a5 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -42,6 +42,9 @@ fr: attachments: form: send_notification_to_followers: Envoyer une notification à toutes les personnes qui suivent la concertation ayant accepté de recevoir des notifications par mail + content_blocks: + update: + error: Une erreur est survenue lors de la mise à jour du bloc de contenu. models: assembly: fields: diff --git a/lib/extends/commands/decidim/admin/content_blocks/update_content_block_extends.rb b/lib/extends/commands/decidim/admin/content_blocks/update_content_block_extends.rb new file mode 100644 index 0000000000..639e06b986 --- /dev/null +++ b/lib/extends/commands/decidim/admin/content_blocks/update_content_block_extends.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "active_support/concern" + +module UpdateContentBlockExtends + extend ActiveSupport::Concern + + included do + private + + def update_content_block_images + params = form.respond_to?(:image_params) ? form.image_params : (form.attributes["images"] || {}) + content_block.manifest.images.each do |image_config| + image_name = image_config[:name] + val = params[image_name] || params[image_name.to_s] + if val + content_block.images_container.send("#{image_name}=", val) + elsif params[:"remove_#{image_name}"] == "1" || params["remove_#{image_name}"] == "1" + content_block.images_container.send("#{image_name}=", nil) + end + end + end + end +end + +Decidim::Admin::ContentBlocks::UpdateContentBlock.include(UpdateContentBlockExtends) diff --git a/lib/extends/controllers/decidim/assemblies/admin/assembly_landing_page_content_blocks_controller_extends.rb b/lib/extends/controllers/decidim/assemblies/admin/assembly_landing_page_content_blocks_controller_extends.rb new file mode 100644 index 0000000000..6f8e8ea74e --- /dev/null +++ b/lib/extends/controllers/decidim/assemblies/admin/assembly_landing_page_content_blocks_controller_extends.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "active_support/concern" + +module AssemblyLandingPageContentBlocksControllerExtends + extend ActiveSupport::Concern + + included do + def update + enforce_permission_to_update_resource + + @form = form(Decidim::Admin::ContentBlockForm).from_params(params) + + Decidim::Admin::ContentBlocks::UpdateContentBlock.call(@form, content_block, content_block_scope) do + on(:ok) do + redirect_to edit_resource_landing_page_path + end + on(:invalid) do + flash.now[:error] = content_block.errors.full_messages.join(", ").presence || t("decidim.admin.content_blocks.update.error", default: "Erreur lors de la mise à jour") + render "decidim/admin/shared/landing_page_content_blocks/edit" + end + end + end + end +end + +Decidim::Assemblies::Admin::AssemblyLandingPageContentBlocksController.include(AssemblyLandingPageContentBlocksControllerExtends) diff --git a/lib/extends/controllers/decidim/participatory_processes/admin/participatory_process_landing_page_content_blocks_controller_extends.rb b/lib/extends/controllers/decidim/participatory_processes/admin/participatory_process_landing_page_content_blocks_controller_extends.rb new file mode 100644 index 0000000000..1077bd5b79 --- /dev/null +++ b/lib/extends/controllers/decidim/participatory_processes/admin/participatory_process_landing_page_content_blocks_controller_extends.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "active_support/concern" + +module ParticipatoryProcessLandingPageContentBlocksControllerExtends + extend ActiveSupport::Concern + + included do + def update + enforce_permission_to_update_resource + + @form = form(Decidim::Admin::ContentBlockForm).from_params(params) + + Decidim::Admin::ContentBlocks::UpdateContentBlock.call(@form, content_block, content_block_scope) do + on(:ok) do + redirect_to edit_resource_landing_page_path + end + on(:invalid) do + flash.now[:error] = content_block.errors.full_messages.join(", ").presence || t("decidim.admin.content_blocks.update.error", default: "Erreur lors de la mise à jour") + render "decidim/admin/shared/landing_page_content_blocks/edit" + end + end + end + end +end + +Decidim::ParticipatoryProcesses::Admin::ParticipatoryProcessLandingPageContentBlocksController.include(ParticipatoryProcessLandingPageContentBlocksControllerExtends) diff --git a/lib/extends/forms/decidim/admin/content_block_form_extends.rb b/lib/extends/forms/decidim/admin/content_block_form_extends.rb new file mode 100644 index 0000000000..7d8932d964 --- /dev/null +++ b/lib/extends/forms/decidim/admin/content_block_form_extends.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "active_support/concern" + +module ContentBlockFormExtends + extend ActiveSupport::Concern + + included do + attribute :images, Hash + end + + def map_model(model) + @images_container = model.images_container + self.images = {} + end + + def images=(value) + if value.is_a?(Hash) + super(value.transform_keys(&:to_sym)) + else + @images_container = value + super({}) + end + end + + def images + @images_container ||= begin + cb_id = id.presence + if cb_id + scope = context.try(:current_participatory_space) + cb = scope ? Decidim::ContentBlock.find_by(id: cb_id, scoped_resource_id: scope.id) : Decidim::ContentBlock.find_by(id: cb_id) + cb&.images_container + end + end + end + + def image_params + attributes["images"] || {} + end +end + +Decidim::Admin::ContentBlockForm.include(ContentBlockFormExtends) diff --git a/lib/extends/models/decidim/content_block_attachment_extends.rb b/lib/extends/models/decidim/content_block_attachment_extends.rb new file mode 100644 index 0000000000..02498ea577 --- /dev/null +++ b/lib/extends/models/decidim/content_block_attachment_extends.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module ContentBlockAttachmentExtends + def uploader + return if content_block.blank? + + config = content_block.manifest.images.find do |image_config| + image_config[:name].to_s == name.to_s + end + + config = content_block.manifest.images.first if config.blank? && name.blank? + + return if config.blank? + + config[:uploader].constantize + end +end + +Decidim::ContentBlockAttachment.prepend(ContentBlockAttachmentExtends) diff --git a/spec/commands/decidim/admin/content_blocks/update_content_block_extends_spec.rb b/spec/commands/decidim/admin/content_blocks/update_content_block_extends_spec.rb new file mode 100644 index 0000000000..b036e11a24 --- /dev/null +++ b/spec/commands/decidim/admin/content_blocks/update_content_block_extends_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Admin + module ContentBlocks + describe UpdateContentBlock do + let(:organization) { create(:organization) } + let(:assembly) { create(:assembly, organization:) } + let(:scope) { assembly } + let(:content_block) do + create(:content_block, + manifest_name: "hero", + scope_name: "assembly_homepage", + scoped_resource_id: assembly.id, + organization:) + end + + let(:form) do + Decidim::Admin::ContentBlockForm.from_params( + "id" => content_block.id, + "settings" => { "button_text_fr" => "Test" }, + "images" => {} + ).with_context(current_organization: organization) + end + + context "when the form has no image params" do + it "does not raise and broadcasts :ok" do + result = nil + + expect do + described_class.call(form, content_block, scope) do + on(:ok) { result = :ok } + on(:invalid) { result = :invalid } + end + end.not_to raise_error + + expect(result).to eq(:ok) + end + end + end + end + end +end diff --git a/spec/forms/decidim/admin/content_block_form_extends_spec.rb b/spec/forms/decidim/admin/content_block_form_extends_spec.rb new file mode 100644 index 0000000000..2154ce7994 --- /dev/null +++ b/spec/forms/decidim/admin/content_block_form_extends_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Admin + describe ContentBlockForm do + subject do + described_class.from_params(attributes).with_context( + current_organization: organization + ) + end + + let(:organization) { create(:organization) } + let(:assembly) { create(:assembly, organization:) } + let(:content_block) do + create(:content_block, + manifest_name: "hero", + scope_name: "assembly_homepage", + scoped_resource_id: assembly.id, + organization:) + end + + let(:attributes) do + { + "id" => content_block.id, + "settings" => { "button_text_fr" => "Test" }, + "images" => { "background_image" => "some-signed-id" } + } + end + + describe "#images" do + it "returns an images_container, never the raw submitted hash" do + expect(subject.images).not_to be_a(Hash) + end + end + + describe "#image_params" do + it "returns the raw submitted params for the command to consume" do + expect(subject.image_params).to be_a(Hash) + expect(subject.image_params[:background_image]).to eq("some-signed-id") + end + end + end + end +end diff --git a/spec/models/decidim/content_block_attachment_extends_spec.rb b/spec/models/decidim/content_block_attachment_extends_spec.rb new file mode 100644 index 0000000000..b11818bbab --- /dev/null +++ b/spec/models/decidim/content_block_attachment_extends_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + describe ContentBlockAttachment do + let(:organization) { create(:organization) } + let(:assembly) { create(:assembly, organization:) } + let(:content_block) do + create(:content_block, + manifest_name: "hero", + scope_name: "assembly_homepage", + scoped_resource_id: assembly.id, + organization:) + end + + describe "#uploader" do + context "when the attachment is persisted with a name matching the manifest" do + subject { content_block.attachments.find_or_initialize_by(name: "background_image") } + + it "returns the configured uploader" do + expect(subject.uploader).to eq(Decidim::BackgroundImageUploader) + end + end + + context "when the attachment is built without a name" do + subject { content_block.attachments.build } + + it "does not return nil" do + expect(subject.uploader).not_to be_nil + end + end + end + end +end diff --git a/spec/requests/decidim/assemblies/admin/landing_page_content_blocks_spec.rb b/spec/requests/decidim/assemblies/admin/landing_page_content_blocks_spec.rb new file mode 100644 index 0000000000..7b38d15c14 --- /dev/null +++ b/spec/requests/decidim/assemblies/admin/landing_page_content_blocks_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Assembly landing page content blocks update" do + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, :confirmed, organization:) } + let(:assembly) { create(:assembly, organization:) } + let!(:content_block) do + create(:content_block, + manifest_name: "hero", + scope_name: "assembly_homepage", + scoped_resource_id: assembly.id, + organization:) + end + + before do + login_as user, scope: :user + host! organization.host + end + + def signed_id_for(file_path, content_type) + blob = ActiveStorage::Blob.create_and_upload!( + io: File.open(file_path), + filename: File.basename(file_path), + content_type: + ) + blob.signed_id + end + + context "when uploading an oversized image" do + it "does not crash and shows the validation error on re-render" do + signed_id = signed_id_for(Decidim::Dev.asset("5000x5000.png"), "image/png") + + patch decidim_admin_assemblies.assembly_landing_page_content_block_path(assembly, content_block), + params: { + content_block: { + settings: { button_text_fr: "Test" }, + images: { background_image: signed_id } + } + } + + expect(response).to have_http_status(:ok) + expect(response.body).not_to include("We're sorry, but something went wrong") + expect(response.body).to include("Images container") + expect(content_block.reload.images_container.background_image).not_to be_attached + end + end + + context "when uploading a valid image" do + it "saves successfully" do + signed_id = signed_id_for(Decidim::Dev.asset("city.jpeg"), "image/jpeg") + + patch decidim_admin_assemblies.assembly_landing_page_content_block_path(assembly, content_block), + params: { + content_block: { + settings: { button_text_fr: "Test" }, + images: { background_image: signed_id } + } + } + + expect(response).to redirect_to(decidim_admin_assemblies.edit_assembly_landing_page_path(assembly)) + expect(content_block.reload.images_container.background_image).to be_attached + end + end +end diff --git a/spec/requests/decidim/participatory_processes/admin/landing_page_content_blocks_spec.rb b/spec/requests/decidim/participatory_processes/admin/landing_page_content_blocks_spec.rb new file mode 100644 index 0000000000..2913308a0c --- /dev/null +++ b/spec/requests/decidim/participatory_processes/admin/landing_page_content_blocks_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Participatory process landing page content blocks update" do + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, :confirmed, organization:) } + let(:participatory_process) { create(:participatory_process, organization:) } + let!(:content_block) do + create(:content_block, + manifest_name: "hero", + scope_name: "participatory_process_homepage", + scoped_resource_id: participatory_process.id, + organization:) + end + + before do + login_as user, scope: :user + host! organization.host + end + + def signed_id_for(file_path, content_type) + blob = ActiveStorage::Blob.create_and_upload!( + io: File.open(file_path), + filename: File.basename(file_path), + content_type: + ) + blob.signed_id + end + + context "when uploading an oversized image" do + it "does not crash and shows the validation error on re-render" do + signed_id = signed_id_for(Decidim::Dev.asset("5000x5000.png"), "image/png") + + patch decidim_admin_participatory_processes.participatory_process_landing_page_content_block_path(participatory_process, content_block), + params: { + content_block: { + settings: { button_text_fr: "Test" }, + images: { background_image: signed_id } + } + } + + expect(response).to have_http_status(:ok) + expect(response.body).not_to include("We're sorry, but something went wrong") + expect(response.body).to include("Images container") + expect(content_block.reload.images_container.background_image).not_to be_attached + end + end + + context "when uploading a valid image" do + it "saves successfully" do + signed_id = signed_id_for(Decidim::Dev.asset("city.jpeg"), "image/jpeg") + + patch decidim_admin_participatory_processes.participatory_process_landing_page_content_block_path(participatory_process, content_block), + params: { + content_block: { + settings: { button_text_fr: "Test" }, + images: { background_image: signed_id } + } + } + + expect(response).to redirect_to(decidim_admin_participatory_processes.edit_participatory_process_landing_page_path(participatory_process)) + expect(content_block.reload.images_container.background_image).to be_attached + end + end +end diff --git a/spec/system/decidim/assemblies/admin/admin_manages_landing_page_content_blocks_spec.rb b/spec/system/decidim/assemblies/admin/admin_manages_landing_page_content_blocks_spec.rb new file mode 100644 index 0000000000..8d246d2671 --- /dev/null +++ b/spec/system/decidim/assemblies/admin/admin_manages_landing_page_content_blocks_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Admin manages assembly landing page content blocks" do + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, :confirmed, organization:) } + let(:assembly) { create(:assembly, organization:) } + let!(:content_block) do + create(:content_block, + manifest_name: "hero", + scope_name: "assembly_homepage", + scoped_resource_id: assembly.id, + organization:) + end + + before do + switch_to_host(organization.host) + login_as user, scope: :user + visit decidim_admin_assemblies.edit_assembly_landing_page_content_block_path(assembly, content_block) + end + + context "when uploading an oversized background image" do + it "shows a validation error instead of crashing" do + dynamically_attach_file(:content_block_images_background_image, Decidim::Dev.asset("5000x5000.png")) + + within ".edit_content_block" do + find("*[type=submit]").click + end + + expect(page).to have_no_content("We're sorry, but something went wrong") + expect(page).to have_content("Images container is invalid") + end + end + + context "when uploading a valid background image" do + it "saves the content block and redirects to the landing page" do + dynamically_attach_file(:content_block_images_background_image, Decidim::Dev.asset("city.jpeg")) + + within ".edit_content_block" do + find("*[type=submit]").click + end + + expect(page).to have_no_content("Images container is invalid") + expect(page).to have_current_path(decidim_admin_assemblies.edit_assembly_landing_page_path(assembly)) + expect(content_block.reload.images_container.background_image).to be_attached + end + end +end From bca45873a54f8c2bbcd35dd93be263eb4115ef87 Mon Sep 17 00:00:00 2001 From: AyakorK Date: Fri, 19 Jun 2026 11:59:42 +0200 Subject: [PATCH 4/7] fix: SSO with extra_user_fields enforcement --- .env-example | 3 + app/controllers/application_controller.rb | 1 + .../unescape_check_box_label.html.erb.deface | 2 +- config/application.rb | 2 + config/locales/en.yml | 7 + config/locales/fr.yml | 7 + config/secrets.yml | 3 + .../application_controller_extends.rb | 56 ++++++ .../decidim/account_controller_extends.rb | 29 ++++ .../devise/invitations_controller_extends.rb | 31 ++++ ...niauth_registrations_controller_extends.rb | 3 +- spec/controllers/account_controller_spec.rb | 105 +++++++++++ .../application_controller_spec.rb | 164 ++++++++++++++++++ .../invitations_controller_spec.rb | 104 +++++++++++ 14 files changed, 514 insertions(+), 3 deletions(-) create mode 100644 lib/extends/controllers/application_controller_extends.rb create mode 100644 lib/extends/controllers/decidim/devise/invitations_controller_extends.rb create mode 100644 spec/controllers/account_controller_spec.rb create mode 100644 spec/controllers/application_controller_spec.rb create mode 100644 spec/controllers/invitations_controller_spec.rb diff --git a/.env-example b/.env-example index a0bfe470af..d38a3bbd13 100644 --- a/.env-example +++ b/.env-example @@ -59,3 +59,6 @@ GEOCODER_UNITS=km # Units for geocoder results (e.g., km or m # DECIDIM_AI_BASIC_AUTH=":" Required for the AI Request Handler # DECIDIM_AI_REPORTING_USER_EMAIL="" # DECIDIM_AI_SECRET="" # Not required for the AI Request Handler + +#= Enforce EUF completion after login +DECIDIM_FORCE_EUF_COMPLETION=true diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7944f9f993..d4c952bcb6 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true class ApplicationController < ActionController::Base + before_action :check_euf_completion, if: :current_user end diff --git a/app/overrides/decidim/shared/filters/_dropdown_label/unescape_check_box_label.html.erb.deface b/app/overrides/decidim/shared/filters/_dropdown_label/unescape_check_box_label.html.erb.deface index e7a18b6b60..fd97c6f6f6 100644 --- a/app/overrides/decidim/shared/filters/_dropdown_label/unescape_check_box_label.html.erb.deface +++ b/app/overrides/decidim/shared/filters/_dropdown_label/unescape_check_box_label.html.erb.deface @@ -1 +1 @@ -<%= filter_text_for(CGI.unescapeHTML(leaf.label), id: "dropdown-title-#{data_checkboxes_tree_id}") %> \ No newline at end of file +<%= filter_text_for(CGI.unescapeHTML(leaf.label.to_s), id: "dropdown-title-#{data_checkboxes_tree_id}") %> \ No newline at end of file diff --git a/config/application.rb b/config/application.rb index 085e00068d..0543e9fb45 100644 --- a/config/application.rb +++ b/config/application.rb @@ -34,6 +34,8 @@ class Application < Rails::Application require "extends/forms/decidim/editor_image_form_extends" require "extends/forms/decidim/admin/content_block_form_extends" # controllers + require "extends/controllers/application_controller_extends" + require "extends/controllers/decidim/devise/invitations_controller_extends" require "extends/controllers/decidim/admin/scopes_controller_extends" require "extends/controllers/decidim/scopes_controller_extends" require "extends/controllers/decidim/comments/comments_controller_extends" diff --git a/config/locales/en.yml b/config/locales/en.yml index 714a9fb81a..77af8e4899 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -23,6 +23,10 @@ en:

You can't edit this information here.

+ update: + error: There was a problem updating your account. + success: Your account was successfully updated. + success_with_email_confirmation: Your account was successfully updated. You will receive an email to confirm your new email address. admin: actions: add: Add @@ -120,6 +124,9 @@ en: email_outro: You received this notification because you are the author of the proposal. You can unfollow it by visiting the proposal page (" %{resource_title} ") and clicking on " Unfollow ". email_subject: Your proposal has been published! notification_title: Your proposal %{resource_title} is now live. + extra_user_fields: + force_euf_completion: + alert: Please complete your profile before continuing. Required fields must be filled in. forms: questionnaire_answer_presenter: download_attachment: Download attachment diff --git a/config/locales/fr.yml b/config/locales/fr.yml index e7ef9c42a5..f7af0918a2 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -31,6 +31,10 @@ fr:

Vous ne pouvez pas modifier ces informations ici.

+ update: + error: Une erreur s'est produite lors de la mise à jour de votre compte. + success: Votre compte a été mis à jour avec succès. + success_with_email_confirmation: Votre compte a été mis à jour avec succès. Vous recevrez un email pour confirmer votre nouvelle adresse. admin: actions: add: Ajouter @@ -128,6 +132,9 @@ fr: email_outro: Vous recevez cette notification car vous êtes l’auteur de la proposition. Vous pouvez vous désabonner en visitant la page de la proposition (« %{resource_title} ») et en cliquant sur « Ne plus suivre ». email_subject: Votre proposition a été publiée ! notification_title: Votre proposition %{resource_title} est maintenant en ligne. + extra_user_fields: + force_euf_completion: + alert: Veuillez compléter votre profil avant de continuer. Les champs obligatoires doivent être renseignés. forms: questionnaire_answer_presenter: download_attachment: Télécharger la pièce jointe diff --git a/config/secrets.yml b/config/secrets.yml index e06913d559..cfb339ec7c 100644 --- a/config/secrets.yml +++ b/config/secrets.yml @@ -133,6 +133,9 @@ default: &default <<: *decidim_default participatory_processes: sort_by_date: <%= ENV.fetch("SORT_PROCESSES_BY_DATE", "false") == "true" %> + extra_user_fields: + force_euf_completion: <%= ENV["DECIDIM_FORCE_EUF_COMPLETION"] %> + sidekiq: concurrency: <%= Decidim::Env.new('SIDEKIQ_CONCURRENCY', '5').to_i %> max_retries: <%= Decidim::Env.new('SIDEKIQ_MAX_RETRIES', '5').to_i %> diff --git a/lib/extends/controllers/application_controller_extends.rb b/lib/extends/controllers/application_controller_extends.rb new file mode 100644 index 0000000000..038e6548aa --- /dev/null +++ b/lib/extends/controllers/application_controller_extends.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module ApplicationControllerExtends + extend ActiveSupport::Concern + + included do + before_action :check_euf_completion, if: :current_user + + private + + def check_euf_completion + return unless euf_completion_enforced? + return if euf_whitelisted_path? + + missing_field = first_missing_euf_field(current_user) + return unless missing_field + + stored = safe_euf_redirect_path? ? request.fullpath : stored_location_for(current_user) + session[:euf_redirect_url] = stored if stored.present? + flash[:alert] = t("decidim.extra_user_fields.force_euf_completion.alert") + redirect_to "#{decidim.account_path}#user_#{missing_field}" + end + + def euf_completion_enforced? + Rails.application.secrets.dig(:decidim, :extra_user_fields, :force_euf_completion) + end + + def euf_whitelisted_path? + whitelisted_paths = ["/account", "/users/sign_out", "/rails/active_storage", "/decidim-packs"] + whitelisted_paths.any? { |path| request.path.start_with?(path) } + end + + def safe_euf_redirect_path? + path = request.fullpath + return false if path.include?("invitation_token") + return false if path.include?("users/auth") + return false if path.start_with?("/users/") + + true + end + + def first_missing_euf_field(user) + return if user.admin? + return unless current_organization.extra_user_fields_enabled? + + [:country, :postal_code, :date_of_birth, :gender, :phone_number, :location, :underage].find do |field| + current_organization.activated_extra_field?(field) && + user.extended_data[field.to_s].blank? + end + end + end +end + +ApplicationController.class_eval do + include(ApplicationControllerExtends) +end diff --git a/lib/extends/controllers/decidim/account_controller_extends.rb b/lib/extends/controllers/decidim/account_controller_extends.rb index abdd2e8244..2187c0c481 100644 --- a/lib/extends/controllers/decidim/account_controller_extends.rb +++ b/lib/extends/controllers/decidim/account_controller_extends.rb @@ -2,6 +2,33 @@ module Decidim module AccountControllerExtends + def update + enforce_permission_to(:update, :user, current_user:) + + @account = form(Decidim::AccountForm).from_params(account_params) + + Decidim::UpdateAccount.call(@account) do + on(:ok) do |email_is_unconfirmed| + flash[:notice] = if email_is_unconfirmed + t("account.update.success_with_email_confirmation", scope: "decidim") + else + t("account.update.success", scope: "decidim") + end + + bypass_sign_in(current_user) + + redirect_url = session.delete(:euf_redirect_url) || decidim.account_path + redirect_to redirect_url + end + + on(:invalid) do |password| + fetch_entered_password(password) + flash[:alert] = t("account.update.error", scope: "decidim") + render action: :show + end + end + end + def destroy enforce_permission_to(:delete, :user, current_user:) @form = form(Decidim::DeleteAccountForm).from_params(params) @@ -48,6 +75,8 @@ def handle_invalid_destruction def account_params params[:user][:name] = current_user.name if disable_profile_field?(:name) params[:user][:email] = current_user.email if disable_profile_field?(:email) + params[:user][:nickname] ||= current_user.nickname + params[:user][:tos_agreement] = "1" params[:user].to_unsafe_h end diff --git a/lib/extends/controllers/decidim/devise/invitations_controller_extends.rb b/lib/extends/controllers/decidim/devise/invitations_controller_extends.rb new file mode 100644 index 0000000000..3731700328 --- /dev/null +++ b/lib/extends/controllers/decidim/devise/invitations_controller_extends.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module ApplicationInvitationsControllerExtends + extend ActiveSupport::Concern + + included do + def after_accept_path_for(resource) + private_user = Decidim::ParticipatorySpacePrivateUser + .where(user: resource) + .order(created_at: :desc) + .first + + if private_user&.privatable_to.present? + space = private_user.privatable_to + destination = case space + when Decidim::Assembly + decidim_assemblies.assembly_path(space.slug) + when Decidim::ParticipatoryProcess + decidim_participatory_processes.participatory_process_path(space.slug) + end + session[:euf_redirect_url] = destination if destination.present? + end + + invite_redirect_path || after_sign_in_path_for(resource) + end + end +end + +Decidim::Devise::InvitationsController.class_eval do + include(ApplicationInvitationsControllerExtends) +end diff --git a/lib/extends/controllers/decidim/devise/omniauth_registrations_controller_extends.rb b/lib/extends/controllers/decidim/devise/omniauth_registrations_controller_extends.rb index 2d47b264b8..2301029551 100644 --- a/lib/extends/controllers/decidim/devise/omniauth_registrations_controller_extends.rb +++ b/lib/extends/controllers/decidim/devise/omniauth_registrations_controller_extends.rb @@ -39,8 +39,7 @@ def create def sign_in_and_redirect(resource_or_scope, *args) strategy = request.env["omniauth.strategy"] - provider = strategy.name - session["omniauth.provider"] = provider + session["omniauth.provider"] = strategy.name if strategy super end diff --git a/spec/controllers/account_controller_spec.rb b/spec/controllers/account_controller_spec.rb new file mode 100644 index 0000000000..8e63dfaf11 --- /dev/null +++ b/spec/controllers/account_controller_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + describe Decidim::AccountController do + routes { Decidim::Core::Engine.routes } + + let(:organization) { create(:organization, extra_user_fields: { "enabled" => true }) } + let(:valid_params) do + { + user: { + name: user.name, + nickname: user.nickname, + email: user.email, + password: "", + old_password: "", + personal_url: "", + about: "", + locale: "fr", + tos_agreement: "1" + } + } + end + let(:user) { create(:user, :confirmed, organization:) } + + before do + request.env["decidim.current_organization"] = organization + request.env["devise.mapping"] = ::Devise.mappings[:user] + sign_in user + end + + def stub_update_account(event, *args) + allow(Decidim::UpdateAccount).to receive(:call) do |_form, &block| + callbacks = {} + allow(controller).to receive(:on) { |ev, &cb| callbacks[ev] = cb } + controller.instance_exec(&block) + callbacks[event]&.call(*args) + end + end + + describe "PUT update" do + context "when UpdateAccount succeeds" do + before { stub_update_account(:ok, false) } + + context "and euf_redirect_url is stored in session" do + before { session[:euf_redirect_url] = "/assemblies/some-assembly" } + + it "redirects to the stored euf_redirect_url" do + put :update, params: valid_params + expect(response).to redirect_to("/assemblies/some-assembly") + end + + it "clears euf_redirect_url from session" do + put :update, params: valid_params + expect(session[:euf_redirect_url]).to be_nil + end + + it "shows the success flash" do + put :update, params: valid_params + expect(flash[:notice]).to be_present + end + end + + context "and euf_redirect_url is a participatory process path" do + before { session[:euf_redirect_url] = "/processes/some-process" } + + it "redirects to the stored process path" do + put :update, params: valid_params + expect(response).to redirect_to("/processes/some-process") + end + end + + context "and no euf_redirect_url is stored in session" do + it "redirects to account path" do + put :update, params: valid_params + expect(response).to redirect_to(account_path) + end + end + end + + context "when UpdateAccount fails" do + before do + stub_update_account(:invalid, false) + session[:euf_redirect_url] = "/assemblies/some-assembly" + end + + it "renders the show template" do + put :update, params: valid_params + expect(response).to render_template(:show) + end + + it "keeps euf_redirect_url in session so the user can retry" do + put :update, params: valid_params + expect(session[:euf_redirect_url]).to eq("/assemblies/some-assembly") + end + + it "shows the error flash" do + put :update, params: valid_params + expect(flash[:alert]).to be_present + end + end + end + end +end diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb new file mode 100644 index 0000000000..b298c505e4 --- /dev/null +++ b/spec/controllers/application_controller_spec.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + describe ApplicationController do + routes { Decidim::Core::Engine.routes } + + let(:organization) { create(:organization, extra_user_fields: { "enabled" => true }) } + let(:user) { create(:user, :confirmed, organization:) } + + before do + request.env["decidim.current_organization"] = organization + request.env["devise.mapping"] = ::Devise.mappings[:user] + sign_in user + allow(controller).to receive(:current_organization).and_return(organization) + end + + describe "#euf_completion_enforced?" do + it "returns true when the secret is enabled" do + allow(Rails.application.secrets).to receive(:dig) + .with(:decidim, :extra_user_fields, :force_euf_completion) + .and_return(true) + expect(controller.send(:euf_completion_enforced?)).to be(true) + end + + it "returns false when the secret is disabled" do + allow(Rails.application.secrets).to receive(:dig) + .with(:decidim, :extra_user_fields, :force_euf_completion) + .and_return(false) + expect(controller.send(:euf_completion_enforced?)).to be(false) + end + + it "returns nil when the secret is not set" do + allow(Rails.application.secrets).to receive(:dig) + .with(:decidim, :extra_user_fields, :force_euf_completion) + .and_return(nil) + expect(controller.send(:euf_completion_enforced?)).to be_nil + end + end + + describe "#euf_whitelisted_path?" do + { + "/account" => true, + "/users/sign_out" => true, + "/rails/active_storage/blobs/xxx" => true, + "/decidim-packs/main.js" => true, + "/assemblies/my-assembly" => false, + "/processes/my-process" => false, + "/initiatives" => false, + "/" => false + }.each do |path, expected| + it "returns #{expected} for #{path}" do + allow(request).to receive(:path).and_return(path) + expect(controller.send(:euf_whitelisted_path?)).to be(expected) + end + end + end + + describe "#safe_euf_redirect_path?" do + it "returns true for a normal assembly path" do + allow(request).to receive(:fullpath).and_return("/assemblies/my-assembly") + expect(controller.send(:safe_euf_redirect_path?)).to be(true) + end + + it "returns true for a process path" do + allow(request).to receive(:fullpath).and_return("/processes/my-process") + expect(controller.send(:safe_euf_redirect_path?)).to be(true) + end + + it "returns false for paths containing invitation_token" do + allow(request).to receive(:fullpath).and_return("/users/invitation/accept?invitation_token=abc123") + expect(controller.send(:safe_euf_redirect_path?)).to be(false) + end + + it "returns false for omniauth paths" do + allow(request).to receive(:fullpath).and_return("/users/auth/openid_connect/callback") + expect(controller.send(:safe_euf_redirect_path?)).to be(false) + end + + it "returns false for paths starting with /users/" do + allow(request).to receive(:fullpath).and_return("/users/sign_in") + expect(controller.send(:safe_euf_redirect_path?)).to be(false) + end + end + + describe "#first_missing_euf_field" do + context "when the user is an admin" do + let(:user) { create(:user, :admin, :confirmed, organization:) } + + it "returns nil" do + expect(controller.send(:first_missing_euf_field, user)).to be_nil + end + end + + context "when extra_user_fields is not enabled on the organization" do + let(:organization) { create(:organization, extra_user_fields: { "enabled" => false }) } + + it "returns nil" do + expect(controller.send(:first_missing_euf_field, user)).to be_nil + end + end + + context "when all fields are filled" do + before do + allow(organization).to receive(:activated_extra_field?).and_return(true) + user.update!(extended_data: { + "phone_number" => "+33612345678", + "country" => "FR", + "postal_code" => "75001", + "date_of_birth" => "1990-01-01", + "gender" => "female", + "location" => "Paris", + "underage" => false + }) + end + + it "returns nil" do + expect(controller.send(:first_missing_euf_field, user)).to be_nil + end + end + + context "when phone_number is activated and missing" do + before do + allow(organization).to receive(:extra_user_fields_enabled?).and_return(true) + allow(organization).to receive(:activated_extra_field?).and_return(false) + allow(organization).to receive(:activated_extra_field?).with(:phone_number).and_return(true) + user.update!(extended_data: { "phone_number" => nil }) + end + + it "returns :phone_number" do + expect(controller.send(:first_missing_euf_field, user)).to eq(:phone_number) + end + end + + context "when country is activated and missing" do + before do + allow(organization).to receive(:extra_user_fields_enabled?).and_return(true) + allow(organization).to receive(:activated_extra_field?).and_return(false) + allow(organization).to receive(:activated_extra_field?).with(:country).and_return(true) + user.update!(extended_data: { "country" => "" }) + end + + it "returns :country" do + expect(controller.send(:first_missing_euf_field, user)).to eq(:country) + end + end + + context "when country and phone_number are activated but only phone_number is missing" do + before do + allow(organization).to receive(:extra_user_fields_enabled?).and_return(true) + allow(organization).to receive(:activated_extra_field?).and_return(false) + allow(organization).to receive(:activated_extra_field?).with(:country).and_return(true) + allow(organization).to receive(:activated_extra_field?).with(:phone_number).and_return(true) + user.update!(extended_data: { "country" => "FR", "phone_number" => nil }) + end + + it "returns :phone_number" do + expect(controller.send(:first_missing_euf_field, user)).to eq(:phone_number) + end + end + end + end +end diff --git a/spec/controllers/invitations_controller_spec.rb b/spec/controllers/invitations_controller_spec.rb new file mode 100644 index 0000000000..068722e009 --- /dev/null +++ b/spec/controllers/invitations_controller_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + describe Decidim::Devise::InvitationsController do + routes { Decidim::Core::Engine.routes } + + let(:organization) { create(:organization) } + let(:user) { create(:user, :confirmed, organization:) } + + before do + request.env["decidim.current_organization"] = organization + request.env["devise.mapping"] = ::Devise.mappings[:user] + sign_in user + allow(controller).to receive(:after_sign_in_path_for).and_return("/account") + end + + describe "#after_accept_path_for" do + subject { controller.after_accept_path_for(user) } + + context "when the user has no private space membership" do + it "does not store euf_redirect_url in session" do + subject + expect(session[:euf_redirect_url]).to be_nil + end + + it "delegates to after_sign_in_path_for" do + allow(controller).to receive(:after_sign_in_path_for).with(user).and_return("/account") + expect(subject).to eq("/account") + subject + end + end + + context "when the user is a member of a private assembly" do + let(:assembly) { create(:assembly, :private, organization:) } + + before do + create(:participatory_space_private_user, user:, privatable_to: assembly) + end + + it "stores the assembly path in euf_redirect_url" do + subject + expect(session[:euf_redirect_url]).to eq("/assemblies/#{assembly.slug}") + end + + it "delegates to after_sign_in_path_for" do + allow(controller).to receive(:after_sign_in_path_for).with(user).and_return("/account") + expect(subject).to eq("/account") + subject + end + end + + context "when the user is a member of a private participatory process" do + let(:participatory_process) { create(:participatory_process, :private, organization:) } + + before do + create(:participatory_space_private_user, user:, privatable_to: participatory_process) + end + + it "stores the participatory process path in euf_redirect_url" do + subject + expect(session[:euf_redirect_url]).to eq("/processes/#{participatory_process.slug}") + end + + it "delegates to after_sign_in_path_for" do + allow(controller).to receive(:after_sign_in_path_for).with(user).and_return("/account") + expect(subject).to eq("/account") + subject + end + end + + context "when the user has multiple private space memberships" do + let(:assembly_old) { create(:assembly, :private, organization:) } + let(:assembly_new) { create(:assembly, :private, organization:) } + + before do + create(:participatory_space_private_user, user:, privatable_to: assembly_old, + created_at: 2.days.ago) + create(:participatory_space_private_user, user:, privatable_to: assembly_new, + created_at: 1.day.ago) + end + + it "stores the most recent membership's space path" do + subject + expect(session[:euf_redirect_url]).to eq("/assemblies/#{assembly_new.slug}") + end + end + + context "when invite_redirect param is present" do + before do + allow(controller).to receive(:params).and_return( + ActionController::Parameters.new(invite_redirect: "/some/path") + ) + end + + it "returns the invite_redirect path without delegating to after_sign_in_path_for" do + expect(controller).not_to receive(:after_sign_in_path_for) + expect(subject).to eq("/some/path") + end + end + end + end +end From 239008146aa14ea80faeaf6ffeff26866956c06c Mon Sep 17 00:00:00 2001 From: AyakorK Date: Mon, 22 Jun 2026 14:00:45 +0200 Subject: [PATCH 5/7] fix: Make sure to not have to re-enter phone_number or informations already fullfiled --- .../create_omniauth_registration_extends.rb | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lib/extends/commands/decidim/create_omniauth_registration_extends.rb b/lib/extends/commands/decidim/create_omniauth_registration_extends.rb index 4e53169e78..505cc662d9 100644 --- a/lib/extends/commands/decidim/create_omniauth_registration_extends.rb +++ b/lib/extends/commands/decidim/create_omniauth_registration_extends.rb @@ -13,6 +13,9 @@ def call return broadcast(:ok, @user) end + + prefill_euf_fields_from_existing_user + return broadcast(:invalid) if form.invalid? transaction do @@ -33,6 +36,24 @@ def manage_user_confirmation # send welcome notification and email @user.after_confirmation if verified_email end + + private + + def prefill_euf_fields_from_existing_user + existing_user = Decidim::User.find_by( + email: form.email, + organization: form.current_organization + ) + return unless existing_user + + existing_data = existing_user.extended_data.presence || {} + %w(phone_number country postal_code date_of_birth gender location underage).each do |field| + next unless form.respond_to?(:"#{field}=") + next if existing_data[field].blank? + + form.public_send(:"#{field}=", existing_data[field]) + end + end end Decidim::CreateOmniauthRegistration.class_eval do From f3300aa1c1c40f8329e6fe297ebca10f8d11323d Mon Sep 17 00:00:00 2001 From: AyakorK Date: Mon, 22 Jun 2026 17:17:34 +0200 Subject: [PATCH 6/7] fix: Add nil guard on secrets --- config/secrets.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/config/secrets.yml b/config/secrets.yml index cfb339ec7c..bb0c3c22ef 100644 --- a/config/secrets.yml +++ b/config/secrets.yml @@ -134,8 +134,7 @@ default: &default participatory_processes: sort_by_date: <%= ENV.fetch("SORT_PROCESSES_BY_DATE", "false") == "true" %> extra_user_fields: - force_euf_completion: <%= ENV["DECIDIM_FORCE_EUF_COMPLETION"] %> - + force_euf_completion: <%= ENV["DECIDIM_FORCE_EUF_COMPLETION"] == "true" %> sidekiq: concurrency: <%= Decidim::Env.new('SIDEKIQ_CONCURRENCY', '5').to_i %> max_retries: <%= Decidim::Env.new('SIDEKIQ_MAX_RETRIES', '5').to_i %> From bd0c85e4483c69eeda7c4287a5f421f8b3ba5546 Mon Sep 17 00:00:00 2001 From: AyakorK Date: Wed, 24 Jun 2026 16:09:17 +0200 Subject: [PATCH 7/7] fix: Try to stabilize redirection --- .../application_controller_extends.rb | 7 +- .../decidim/account_controller_extends.rb | 21 ++- .../devise/invitations_controller_extends.rb | 2 +- spec/controllers/account_controller_spec.rb | 77 +++++---- .../invitations_controller_spec.rb | 45 +++--- spec/system/euf_completion_spec.rb | 147 ++++++++++++++++++ 6 files changed, 247 insertions(+), 52 deletions(-) create mode 100644 spec/system/euf_completion_spec.rb diff --git a/lib/extends/controllers/application_controller_extends.rb b/lib/extends/controllers/application_controller_extends.rb index 038e6548aa..86f15532a7 100644 --- a/lib/extends/controllers/application_controller_extends.rb +++ b/lib/extends/controllers/application_controller_extends.rb @@ -15,7 +15,12 @@ def check_euf_completion missing_field = first_missing_euf_field(current_user) return unless missing_field - stored = safe_euf_redirect_path? ? request.fullpath : stored_location_for(current_user) + existing = session["user_return_to"] + stored = if existing.present? + existing + elsif safe_euf_redirect_path? + request.fullpath + end session[:euf_redirect_url] = stored if stored.present? flash[:alert] = t("decidim.extra_user_fields.force_euf_completion.alert") redirect_to "#{decidim.account_path}#user_#{missing_field}" diff --git a/lib/extends/controllers/decidim/account_controller_extends.rb b/lib/extends/controllers/decidim/account_controller_extends.rb index 2187c0c481..8061a393cd 100644 --- a/lib/extends/controllers/decidim/account_controller_extends.rb +++ b/lib/extends/controllers/decidim/account_controller_extends.rb @@ -17,7 +17,9 @@ def update bypass_sign_in(current_user) - redirect_url = session.delete(:euf_redirect_url) || decidim.account_path + redirect_url = session.delete(:euf_redirect_url) || + stored_location_for(current_user) || + decidim.account_path redirect_to redirect_url end @@ -44,6 +46,23 @@ def destroy private + def private_space_path_for(user) + private_user = Decidim::ParticipatorySpacePrivateUser + .where(user:) + .order(created_at: :desc) + .first + + return if private_user&.privatable_to.blank? + + space = private_user.privatable_to + case space + when Decidim::Assembly + decidim_assemblies.assembly_path(space.slug) + when Decidim::ParticipatoryProcess + decidim_participatory_processes.participatory_process_path(space.slug) + end + end + def handle_successful_destruction sign_out(current_user) flash[:notice] = t("account.destroy.success", scope: "decidim") diff --git a/lib/extends/controllers/decidim/devise/invitations_controller_extends.rb b/lib/extends/controllers/decidim/devise/invitations_controller_extends.rb index 3731700328..c79d9d8684 100644 --- a/lib/extends/controllers/decidim/devise/invitations_controller_extends.rb +++ b/lib/extends/controllers/decidim/devise/invitations_controller_extends.rb @@ -18,7 +18,7 @@ def after_accept_path_for(resource) when Decidim::ParticipatoryProcess decidim_participatory_processes.participatory_process_path(space.slug) end - session[:euf_redirect_url] = destination if destination.present? + store_location_for(resource, destination) if destination.present? end invite_redirect_path || after_sign_in_path_for(resource) diff --git a/spec/controllers/account_controller_spec.rb b/spec/controllers/account_controller_spec.rb index 8e63dfaf11..41e62e2a04 100644 --- a/spec/controllers/account_controller_spec.rb +++ b/spec/controllers/account_controller_spec.rb @@ -30,74 +30,97 @@ module Decidim sign_in user end - def stub_update_account(event, *args) + def stub_update_account_ok allow(Decidim::UpdateAccount).to receive(:call) do |_form, &block| callbacks = {} allow(controller).to receive(:on) { |ev, &cb| callbacks[ev] = cb } controller.instance_exec(&block) - callbacks[event]&.call(*args) + callbacks[:ok]&.call(false) end end - describe "PUT update" do - context "when UpdateAccount succeeds" do - before { stub_update_account(:ok, false) } + describe "POST update redirection after EUF completion" do + before { stub_update_account_ok } - context "and euf_redirect_url is stored in session" do - before { session[:euf_redirect_url] = "/assemblies/some-assembly" } + context "when session[:euf_redirect_url] is set (normal navigation interception)" do + context "and points to an assembly" do + before { session[:euf_redirect_url] = "/assemblies/my-assembly" } - it "redirects to the stored euf_redirect_url" do + it "redirects to the assembly" do put :update, params: valid_params - expect(response).to redirect_to("/assemblies/some-assembly") + expect(response).to redirect_to("/assemblies/my-assembly") end it "clears euf_redirect_url from session" do put :update, params: valid_params expect(session[:euf_redirect_url]).to be_nil end + end + + context "and points to a participatory process" do + before { session[:euf_redirect_url] = "/processes/my-process" } - it "shows the success flash" do + it "redirects to the process" do put :update, params: valid_params - expect(flash[:notice]).to be_present + expect(response).to redirect_to("/processes/my-process") + end + + it "clears euf_redirect_url from session" do + put :update, params: valid_params + expect(session[:euf_redirect_url]).to be_nil end end + end - context "and euf_redirect_url is a participatory process path" do - before { session[:euf_redirect_url] = "/processes/some-process" } + context "when stored_location_for is set (invitation flow)" do + context "and points to an assembly" do + before do + allow(controller).to receive(:stored_location_for).and_return("/assemblies/private-assembly") + end - it "redirects to the stored process path" do + it "redirects to the assembly" do put :update, params: valid_params - expect(response).to redirect_to("/processes/some-process") + expect(response).to redirect_to("/assemblies/private-assembly") end end - context "and no euf_redirect_url is stored in session" do - it "redirects to account path" do + context "and points to a participatory process" do + before do + allow(controller).to receive(:stored_location_for).and_return("/processes/private-process") + end + + it "redirects to the process" do put :update, params: valid_params - expect(response).to redirect_to(account_path) + expect(response).to redirect_to("/processes/private-process") end end end - context "when UpdateAccount fails" do + context "when both session[:euf_redirect_url] and stored_location_for are set" do before do - stub_update_account(:invalid, false) - session[:euf_redirect_url] = "/assemblies/some-assembly" + session[:euf_redirect_url] = "/assemblies/from-session" + allow(controller).to receive(:stored_location_for).and_return("/assemblies/from-devise") end - it "renders the show template" do + it "prioritizes session[:euf_redirect_url]" do put :update, params: valid_params - expect(response).to render_template(:show) + expect(response).to redirect_to("/assemblies/from-session") end + end - it "keeps euf_redirect_url in session so the user can retry" do + context "when neither session[:euf_redirect_url] nor stored_location_for are set" do + it "redirects to account path" do put :update, params: valid_params - expect(session[:euf_redirect_url]).to eq("/assemblies/some-assembly") + expect(response).to redirect_to(account_path) end + end + + context "when stored_location_for returns nil" do + before { allow(controller).to receive(:stored_location_for).and_return(nil) } - it "shows the error flash" do + it "redirects to account path" do put :update, params: valid_params - expect(flash[:alert]).to be_present + expect(response).to redirect_to(account_path) end end end diff --git a/spec/controllers/invitations_controller_spec.rb b/spec/controllers/invitations_controller_spec.rb index 068722e009..9d0f5ddfd9 100644 --- a/spec/controllers/invitations_controller_spec.rb +++ b/spec/controllers/invitations_controller_spec.rb @@ -19,19 +19,6 @@ module Decidim describe "#after_accept_path_for" do subject { controller.after_accept_path_for(user) } - context "when the user has no private space membership" do - it "does not store euf_redirect_url in session" do - subject - expect(session[:euf_redirect_url]).to be_nil - end - - it "delegates to after_sign_in_path_for" do - allow(controller).to receive(:after_sign_in_path_for).with(user).and_return("/account") - expect(subject).to eq("/account") - subject - end - end - context "when the user is a member of a private assembly" do let(:assembly) { create(:assembly, :private, organization:) } @@ -39,15 +26,15 @@ module Decidim create(:participatory_space_private_user, user:, privatable_to: assembly) end - it "stores the assembly path in euf_redirect_url" do + it "stores the assembly path via store_location_for" do + expect(controller).to receive(:store_location_for) + .with(user, "/assemblies/#{assembly.slug}") subject - expect(session[:euf_redirect_url]).to eq("/assemblies/#{assembly.slug}") end - it "delegates to after_sign_in_path_for" do - allow(controller).to receive(:after_sign_in_path_for).with(user).and_return("/account") - expect(subject).to eq("/account") + it "does not store in session[:euf_redirect_url]" do subject + expect(session[:euf_redirect_url]).to be_nil end end @@ -58,9 +45,22 @@ module Decidim create(:participatory_space_private_user, user:, privatable_to: participatory_process) end - it "stores the participatory process path in euf_redirect_url" do + it "stores the process path via store_location_for" do + expect(controller).to receive(:store_location_for) + .with(user, "/processes/#{participatory_process.slug}") + subject + end + + it "does not store in session[:euf_redirect_url]" do + subject + expect(session[:euf_redirect_url]).to be_nil + end + end + + context "when the user has no private space membership" do + it "does not call store_location_for" do + expect(controller).not_to receive(:store_location_for) subject - expect(session[:euf_redirect_url]).to eq("/processes/#{participatory_process.slug}") end it "delegates to after_sign_in_path_for" do @@ -82,8 +82,9 @@ module Decidim end it "stores the most recent membership's space path" do + expect(controller).to receive(:store_location_for) + .with(user, "/assemblies/#{assembly_new.slug}") subject - expect(session[:euf_redirect_url]).to eq("/assemblies/#{assembly_new.slug}") end end @@ -94,7 +95,7 @@ module Decidim ) end - it "returns the invite_redirect path without delegating to after_sign_in_path_for" do + it "returns the invite_redirect path" do expect(controller).not_to receive(:after_sign_in_path_for) expect(subject).to eq("/some/path") end diff --git a/spec/system/euf_completion_spec.rb b/spec/system/euf_completion_spec.rb new file mode 100644 index 0000000000..5251be5365 --- /dev/null +++ b/spec/system/euf_completion_spec.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "EUF Force Completion" do + let(:organization) do + create(:organization, extra_user_fields: { + "enabled" => true, + "phone_number" => { "enabled" => true, "required" => true } + }) + end + let(:password) { "dqCFgjfDbC7dPbrv" } + let(:user) { create(:user, :confirmed, password:, organization:, extended_data: {}) } + let!(:assembly) { create(:assembly, :published, organization:) } + + before do + switch_to_host(organization.host) + allow(Rails.application.secrets).to receive(:dig).and_call_original + allow(Rails.application.secrets).to receive(:dig) + .with(:decidim, :extra_user_fields, :force_euf_completion) + .and_return(true) + end + + context "when FORCE_EUF_COMPLETION is enabled and phone_number is missing" do + before { login_as user, scope: :user } + + it "intercepts navigation and redirects to account" do + visit decidim_assemblies.assemblies_path + expect(page).to have_current_path(%r{/account}) + expect(page).to have_content(I18n.t("decidim.extra_user_fields.force_euf_completion.alert")) + end + + it "does not intercept /account itself" do + visit decidim.account_path + expect(page).to have_current_path(decidim.account_path, ignore_query: true) + expect(page).to have_css("form.edit_user") + end + + it "does not intercept /users/sign_out" do + visit decidim.destroy_user_session_path + expect(page).to have_no_content(I18n.t("decidim.extra_user_fields.force_euf_completion.alert")) + end + + context "when completing the profile from a normal navigation interception" do + it "redirects back to the original destination after saving" do + visit decidim_assemblies.assemblies_path + expect(page).to have_current_path(%r{/account}) + + within "form.edit_user" do + fill_in "Phone Number", with: "+33612345678", match: :first + find("*[type=submit]").click + end + + within_flash_messages do + expect(page).to have_content("successfully") + end + + expect(page).to have_current_path(decidim_assemblies.assemblies_path, ignore_query: true) + end + end + + context "when the user is an admin" do + let(:user) { create(:user, :confirmed, :admin, password:, organization:, extended_data: {}) } + + it "does not intercept navigation" do + visit decidim_assemblies.assemblies_path + expect(page).to have_current_path(decidim_assemblies.assemblies_path, ignore_query: true) + expect(page).to have_no_content(I18n.t("decidim.extra_user_fields.force_euf_completion.alert")) + end + end + end + + context "when FORCE_EUF_COMPLETION is disabled" do + before do + allow(Rails.application.secrets).to receive(:dig) + .with(:decidim, :extra_user_fields, :force_euf_completion) + .and_return(false) + login_as user, scope: :user + end + + it "does not intercept navigation" do + visit decidim_assemblies.assemblies_path + expect(page).to have_current_path(decidim_assemblies.assemblies_path, ignore_query: true) + expect(page).to have_no_content(I18n.t("decidim.extra_user_fields.force_euf_completion.alert")) + end + end + + context "when phone_number is already filled" do + let(:user) do + create(:user, :confirmed, password:, organization:, + extended_data: { "phone_number" => "+33612345678" }) + end + + before { login_as user, scope: :user } + + it "does not intercept navigation" do + visit decidim_assemblies.assemblies_path + expect(page).to have_current_path(decidim_assemblies.assemblies_path, ignore_query: true) + expect(page).to have_no_content(I18n.t("decidim.extra_user_fields.force_euf_completion.alert")) + end + end + + context "when user is invited to a private assembly" do + let(:private_assembly) { create(:assembly, :published, :private, organization:) } + let(:invited_user) do + Decidim::User.invite!( + { email: "invited@example.com", name: "Invited User", organization: }, + nil, + { extended_data: {} } + ) + end + + before do + create(:participatory_space_private_user, user: invited_user, privatable_to: private_assembly) + end + + it "redirects to the assembly after completing the profile" do + visit decidim.accept_user_invitation_path( + invitation_token: invited_user.raw_invitation_token + ) + + within "#invitation_edit_user" do + fill_in "user[nickname]", with: "invited_user" + fill_in "user[password]", with: password, match: :first + check "user[tos_agreement]" + find("*[type=submit]").click + end + + expect(page).to have_current_path(%r{/account}) + expect(page).to have_content(I18n.t("decidim.extra_user_fields.force_euf_completion.alert")) + + within "form.edit_user" do + fill_in "Phone Number", with: "+33612345678", match: :first + find("*[type=submit]").click + end + + within_flash_messages do + expect(page).to have_content("successfully") + end + + expect(page).to have_current_path( + decidim_assemblies.assembly_path(private_assembly.slug), + ignore_query: true + ) + end + end +end