From 97d04dcc9855c9184f774031b060ea47801a6fb9 Mon Sep 17 00:00:00 2001 From: moustachu Date: Wed, 15 Jan 2025 16:26:39 +0100 Subject: [PATCH 01/19] fix!(omniauth): keep verified email in session between registration form when needed --- config/application.rb | 1 + ...niauth_registrations_controller_extends.rb | 50 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 lib/extends/controllers/decidim/devise/omniauth_registrations_controller_extends.rb diff --git a/config/application.rb b/config/application.rb index 759eb48b0d..b18de87ff4 100644 --- a/config/application.rb +++ b/config/application.rb @@ -48,6 +48,7 @@ class Application < Rails::Application config.after_initialize do # Controllers require "extends/controllers/decidim/devise/sessions_controller_extends" + require "extends/controllers/decidim/devise/omniauth_registrations_controller_extends" require "extends/controllers/decidim/editor_images_controller_extends" require "extends/controllers/decidim/proposals/proposals_controller_extends" require "extends/controllers/decidim/newsletters_controller_extends" diff --git a/lib/extends/controllers/decidim/devise/omniauth_registrations_controller_extends.rb b/lib/extends/controllers/decidim/devise/omniauth_registrations_controller_extends.rb new file mode 100644 index 0000000000..1922fe4657 --- /dev/null +++ b/lib/extends/controllers/decidim/devise/omniauth_registrations_controller_extends.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module OmniauthRegistrationsControllerExtends + extend ActiveSupport::Concern + + included do + def create + form_params = user_params_from_oauth_hash || params[:user] + + @form = form(Decidim::OmniauthRegistrationForm).from_params(form_params) + @form.email ||= verified_email + + Decidim::CreateOmniauthRegistration.call(@form, verified_email) do + on(:ok) do |user| + if user.active_for_authentication? + sign_in_and_redirect user, event: :authentication + set_flash_message :notice, :success, kind: @form.provider.capitalize + else + expire_data_after_sign_in! + user.resend_confirmation_instructions unless user.confirmed? + redirect_to decidim.root_path + flash[:notice] = t("devise.registrations.signed_up_but_unconfirmed") + end + end + + on(:invalid) do + set_flash_message :notice, :success, kind: @form.provider.capitalize + session["devise.omniauth.verified_email"] = verified_email + render :new + end + + on(:error) do |user| + set_flash_message :alert, :failure, kind: @form.provider.capitalize, reason: t("decidim.devise.omniauth_registrations.create.email_already_exists") if user.errors[:email] + session["devise.omniauth.verified_email"] = verified_email + render :new + end + end + end + + private + + def verified_email + @verified_email ||= oauth_data.dig(:info, :email) || session.delete("devise.omniauth.verified_email") + end + end +end + +Decidim::Devise::OmniauthRegistrationsController.class_eval do + include(OmniauthRegistrationsControllerExtends) +end From ffa06e1b865f2abf5eddc67e60074832d3b37625 Mon Sep 17 00:00:00 2001 From: Guillaume MORET <90462045+AyakorK@users.noreply.github.com> Date: Thu, 13 Feb 2025 09:21:19 +0100 Subject: [PATCH 02/19] fix: Fix displaying of medias on a project's page (#672) * fix: Add a deface override to replace the sanitize editor to display medias * fix: Fix tests --- .github/workflows/ci_cd.yml | 8 ++++---- .../show/fix_videos_and_images_display.html.erb.deface | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 app/overrides/decidim/budgets/projects/show/fix_videos_and_images_display.html.erb.deface diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index d0c9de99da..991531d796 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -118,12 +118,12 @@ jobs: name: RSpec # - run: ./.github/upload_coverage.sh decidim-app $GITHUB_EVENT_PATH # name: Upload coverage - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: always() with: name: screenshots path: ./spec/tmp/screenshots - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: always() with: name: assets-manifest-${{ matrix.slice }} @@ -192,12 +192,12 @@ jobs: name: RSpec # - run: ./.github/upload_coverage.sh decidim-app $GITHUB_EVENT_PATH # name: Upload coverage - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: always() with: name: screenshots path: ./spec/tmp/screenshots - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: always() with: name: assets-manifest-${{ matrix.slice }} diff --git a/app/overrides/decidim/budgets/projects/show/fix_videos_and_images_display.html.erb.deface b/app/overrides/decidim/budgets/projects/show/fix_videos_and_images_display.html.erb.deface new file mode 100644 index 0000000000..11f2d49382 --- /dev/null +++ b/app/overrides/decidim/budgets/projects/show/fix_videos_and_images_display.html.erb.deface @@ -0,0 +1,3 @@ + + +<%= decidim_sanitize_editor_admin translated_attribute project.description %> From ca69047e8cfb4e90dc91bfbfb4c771b46a3a6b92 Mon Sep 17 00:00:00 2001 From: Guillaume MORET <90462045+AyakorK@users.noreply.github.com> Date: Thu, 13 Feb 2025 16:54:57 +0100 Subject: [PATCH 03/19] fix: Update L182 & L256 to add double quotes (#674) --- .../decidim/direct_uploads/upload_modal.js | 263 ++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 app/packs/src/decidim/direct_uploads/upload_modal.js diff --git a/app/packs/src/decidim/direct_uploads/upload_modal.js b/app/packs/src/decidim/direct_uploads/upload_modal.js new file mode 100644 index 0000000000..88a84ecdcc --- /dev/null +++ b/app/packs/src/decidim/direct_uploads/upload_modal.js @@ -0,0 +1,263 @@ + import { Uploader } from "src/decidim/direct_uploads/uploader"; + import { truncateFilename, checkTitles, createHiddenInput } from "src/decidim/direct_uploads/upload_utility"; + + // This class handles logic inside upload modal, but since modal is not inside the form + // logic here moves "upload items" / hidden inputs to form. + export default class UploadModal { + constructor(button, options = {}) { + // Button that opens the modal. + this.button = button; + + // The provided options contains the options passed from the view in the + // `data-upload` attribute as a JSON. + let providedOptions = {}; + try { + // The providedOptions can contain the following keys: + // - addAttribute - Field name / attribute of resource (e.g. avatar) + // - resourceName - The resource to which the attribute belongs (e.g. user) + // - resourceClass - Ruby class of the resource (e.g. Decidim::User) + // - multiple - Defines if multiple files can be uploaded + // - titled - Defines if file(s) can have titles + // - maxFileSize - Defines maximum file size in bytes + // - formObjectClass - Class of the current form object (e.g. Decidim::AccountForm) + providedOptions = JSON.parse(button.dataset.upload); + } catch (_e) { + // Don't care about the parse errors, just skip the provided options. + } + + this.options = Object.assign(providedOptions, options) + + this.name = this.button.name; + this.modal = document.querySelector(`#${button.dataset.open}`); + this.saveButton = this.modal.querySelector(`button.add-file-${this.name}`); + this.attachmentCounter = 0; + this.dropZoneEnabled = true; + this.modalTitle = this.modal.querySelector(".reveal__title"); + this.uploadItems = this.modal.querySelector(".upload-items"); + this.locales = JSON.parse(this.uploadItems.dataset.locales); + this.dropZone = this.modal.querySelector(".dropzone"); + this.input = this.dropZone.querySelector("input"); + this.uploadContainer = document.querySelector(`.upload-container-for-${this.name}`); + this.activeAttachments = this.uploadContainer.querySelector(".active-uploads"); + this.trashCan = this.createTrashCan(); + } + + uploadFile(file) { + if (!this.dropZoneEnabled) { + return; + } + + const title = file.name.split(".")[0].slice(0, 31); + const uploadItem = this.createUploadItem(file.name, title, "init"); + const uploader = new Uploader(this, uploadItem, { + file: file, + url: this.input.dataset.directUploadUrl, + attachmentName: file.name + }); + if (uploader.fileTooBig) { + return; + } + + uploader.upload.create((error, blob) => { + if (error) { + uploadItem.dataset.state = "error"; + const progressBar = uploadItem.querySelector(".progress-bar"); + progressBar.classList.add("filled"); + progressBar.innerHTML = this.locales.error; + console.error(error); + } else { + const ordinalNumber = this.getOrdinalNumber(); + + const attachmentDetails = document.createElement("div"); + attachmentDetails.classList.add("attachment-details"); + attachmentDetails.dataset.filename = file.name; + const titleAndFileNameSpan = document.createElement("span"); + titleAndFileNameSpan.style.display = "none"; + attachmentDetails.appendChild(titleAndFileNameSpan); + + const hiddenBlobField = createHiddenInput(null, null, blob.signed_id); + if (this.options.titled) { + hiddenBlobField.name = `${this.options.resourceName}[${this.options.addAttribute}][${ordinalNumber}][file]`; + } else { + hiddenBlobField.name = `${this.options.resourceName}[${this.options.addAttribute}]`; + } + + if (this.options.titled) { + const hiddenTitleField = createHiddenInput("hidden-title", `${this.options.resourceName}[${this.options.addAttribute}][${ordinalNumber}][title]`, title); + titleAndFileNameSpan.innerHTML = `${title} (${file.name})`; + attachmentDetails.appendChild(hiddenTitleField); + } else { + titleAndFileNameSpan.innerHTML = file.name; + } + + if (!this.options.multiple) { + this.cleanTrashCan(); + } + + attachmentDetails.appendChild(hiddenBlobField); + uploadItem.appendChild(attachmentDetails); + uploader.validate(blob.signed_id); + } + }); + this.updateDropZone(); + } + + getOrdinalNumber() { + const nextOrdinalNumber = this.attachmentCounter; + this.attachmentCounter += 1; + return nextOrdinalNumber; + } + + updateDropZone() { + if (this.options.multiple) { + return; + } + + if (this.uploadItems.children.length > 0) { + this.dropZone.classList.add("disabled"); + this.dropZoneEnabled = false; + this.input.disabled = true; + } else { + this.dropZone.classList.remove("disabled"); + this.dropZoneEnabled = true; + this.input.disabled = false; + } + } + + createUploadItem(fileName, title, state) { + const wrapper = document.createElement("div"); + wrapper.classList.add("upload-item"); + wrapper.setAttribute("data-filename", fileName); + + const firstRow = document.createElement("div"); + const secondRow = document.createElement("div"); + const thirdRow = document.createElement("div"); + firstRow.classList.add("row", "upload-item-first-row"); + secondRow.classList.add("row", "upload-item-second-row"); + thirdRow.classList.add("row", "upload-item-third-row"); + + const fileNameSpan = document.createElement("span"); + let fileNameSpanClasses = ["columns", "file-name-span"]; + if (this.options.titled) { + fileNameSpanClasses.push("small-4", "medium-5"); + } else { + fileNameSpanClasses.push("small-12"); + } + fileNameSpan.classList.add(...fileNameSpanClasses); + fileNameSpan.innerHTML = truncateFilename(fileName); + + const progressBar = document.createElement("div"); + progressBar.classList.add("progress-bar"); + if (state) { + if (state === "validated") { + progressBar.innerHTML = this.locales.uploaded; + } else { + progressBar.innerHTML = "0%"; + progressBar.style.width = "15%"; + } + wrapper.dataset.state = state; + } + + const progressBarBorder = document.createElement("div"); + progressBarBorder.classList.add("progress-bar-border"); + progressBarBorder.appendChild(progressBar); + + const progressBarWrapper = document.createElement("div"); + progressBarWrapper.classList.add("columns", "progress-bar-wrapper"); + progressBarWrapper.appendChild(progressBarBorder); + if (this.options.titled) { + progressBarWrapper.classList.add("small-4", "medium-5"); + } else { + progressBarWrapper.classList.add("small-10"); + } + + const errorList = document.createElement("ul"); + errorList.classList.add("upload-errors"); + + const removeButton = document.createElement("button"); + removeButton.classList.add("columns", "small-3", "medium-2", "remove-upload-item"); + removeButton.innerHTML = `× ${this.locales.remove}`; + removeButton.addEventListener(("click"), (event) => { + event.preventDefault(); + const item = this.uploadItems.querySelector(`[data-filename="${fileName}"]`); + this.trashCan.append(item); + this.updateDropZone(); + }) + + const titleAndFileNameSpan = document.createElement("span"); + titleAndFileNameSpan.classList.add("columns", "small-5", "title-and-filename-span"); + titleAndFileNameSpan.innerHTML = `${title} (${truncateFilename(fileName)})`; + + firstRow.appendChild(fileNameSpan); + secondRow.appendChild(progressBarWrapper); + thirdRow.appendChild(errorList); + + let titleInputContainer = null; + if (this.options.titled) { + const titleInput = document.createElement("input"); + titleInput.classList.add("attachment-title"); + titleInput.type = "text"; + titleInput.value = title; + titleInput.addEventListener("input", (event) => { + event.preventDefault(); + checkTitles(this.uploadItems, this.saveButton); + }) + titleInputContainer = document.createElement("div"); + titleInputContainer.classList.add("columns", "small-5", "title-input-container"); + titleInputContainer.appendChild(titleInput); + + const noTitleErrorSpan = document.createElement("span"); + noTitleErrorSpan.classList.add("form-error", "no-title-error"); + noTitleErrorSpan.role = "alert"; + noTitleErrorSpan.innerHTML = this.locales.title_required; + titleInputContainer.appendChild(noTitleErrorSpan); + + const titleLabelSpan = document.createElement("span"); + titleLabelSpan.classList.add("title-label-span"); + titleLabelSpan.innerHTML = this.locales.title; + + const titleContainer = document.createElement("div"); + titleContainer.classList.add("columns", "small-8", "medium-7", "title-container"); + titleContainer.appendChild(titleLabelSpan); + firstRow.appendChild(titleContainer); + secondRow.appendChild(titleInputContainer); + } + + secondRow.appendChild(removeButton); + + wrapper.appendChild(firstRow); + wrapper.appendChild(secondRow); + wrapper.appendChild(thirdRow); + + this.uploadItems.appendChild(wrapper); + + return wrapper; + } + + updateAddAttachmentsButton() { + if (this.activeAttachments.children.length === 0) { + this.button.innerHTML = this.modalTitle.dataset.addlabel; + } else { + this.button.innerHTML = this.modalTitle.dataset.editlabel; + } + } + + createTrashCan() { + const trashCan = document.createElement("div"); + trashCan.classList.add("trash-can"); + trashCan.style.display = "none"; + this.uploadItems.parentElement.appendChild(trashCan); + return trashCan; + } + + cleanTrashCan() { + Array.from(this.trashCan.children).forEach((item) => { + const fileName = item.dataset.filename; + const activeAttachment = this.activeAttachments.querySelector(`div[data-filename="${fileName}"]`); + if (activeAttachment) { + activeAttachment.remove(); + } + item.remove(); + }) + } + } From 9b464209a9317dc99a96125912a4e006245e134f Mon Sep 17 00:00:00 2001 From: stephanierousset <61418966+Stef-Rousset@users.noreply.github.com> Date: Thu, 13 Feb 2025 17:22:38 +0100 Subject: [PATCH 04/19] Fix/export issue for assemblies admin (#675) * fix: add assembly admin for admin export * test: update export job test with asssembly admin * ci: update action upload-artifact --------- Co-authored-by: Guillaume MORET <90462045+AyakorK@users.noreply.github.com> --- app/jobs/decidim/export_job.rb | 8 +- spec/jobs/export_job_spec.rb | 191 +++++++++++++++++++++------------ 2 files changed, 132 insertions(+), 67 deletions(-) diff --git a/app/jobs/decidim/export_job.rb b/app/jobs/decidim/export_job.rb index aa3477a262..d9a99ad073 100644 --- a/app/jobs/decidim/export_job.rb +++ b/app/jobs/decidim/export_job.rb @@ -12,7 +12,7 @@ def perform(user, component, name, format, resource_id = nil) collection = export_manifest.collection.call(component, user, resource_id) serializer = export_manifest.serializer - export_data = if (serializer == Decidim::Proposals::ProposalSerializer) && (user.admin? || admin_of_process?(user, component)) + export_data = if (serializer == Decidim::Proposals::ProposalSerializer) && (user.admin? || admin_of_process?(user, component) || admin_of_assembly?(user, component)) Decidim::Exporters.find_exporter(format).new(collection, serializer).admin_export else Decidim::Exporters.find_exporter(format).new(collection, serializer).export @@ -27,5 +27,11 @@ def admin_of_process?(user, component) Decidim::ParticipatoryProcessUserRole.exists?(decidim_user_id: user.id, decidim_participatory_process_id: component.participatory_space.id, role: "admin") end + + def admin_of_assembly?(user, component) + return unless component.respond_to?(:participatory_space) + + Decidim::AssemblyUserRole.exists?(decidim_user_id: user.id, decidim_assembly_id: component.participatory_space.id, role: "admin") + end end end diff --git a/spec/jobs/export_job_spec.rb b/spec/jobs/export_job_spec.rb index cc923a7a3c..a7b09da2e1 100644 --- a/spec/jobs/export_job_spec.rb +++ b/spec/jobs/export_job_spec.rb @@ -11,6 +11,8 @@ module Admin let!(:admin) { create(:user, :admin, organization: organization) } let!(:admin_of_the_process) { create(:user, organization: organization) } let!(:participatory_process) { create(:participatory_process, organization: organization) } + let!(:assembly) { create(:assembly, organization: organization) } + let!(:admin_of_the_assembly) { create(:user, organization: organization) } let(:proposal) { create(:extended_proposal) } let(:collection) { [proposal] } # Use an array with the instance_double let(:export_manifest) do @@ -24,100 +26,157 @@ module Admin ) end - before do - component.update!(participatory_space: participatory_process) - create(:participatory_process_user_role, user: admin_of_the_process, participatory_process: participatory_process, role: "admin") + describe "export for processes" do + before do + component.update!(participatory_space: participatory_process) + create(:participatory_process_user_role, user: admin_of_the_process, participatory_process: participatory_process, role: "admin") - allow(component.manifest).to receive(:export_manifests).and_return([export_manifest]) - end + allow(component.manifest).to receive(:export_manifests).and_return([export_manifest]) + end - it "sends an email with the result of the export" do - ExportJob.perform_now(user, component, "proposals", "CSV") + it "sends an email with the result of the export" do + ExportJob.perform_now(user, component, "proposals", "CSV") - email = last_email - expect(email.subject).to include("proposals") - attachment = email.attachments.first + email = last_email + expect(email.subject).to include("proposals") + attachment = email.attachments.first - expect(attachment.read.length).to be_positive - expect(attachment.mime_type).to eq("application/zip") - expect(attachment.filename).to match(/^proposals-[0-9]+-[0-9]+-[0-9]+-[0-9]+\.zip$/) - end + expect(attachment.read.length).to be_positive + expect(attachment.mime_type).to eq("application/zip") + expect(attachment.filename).to match(/^proposals-[0-9]+-[0-9]+-[0-9]+-[0-9]+\.zip$/) + end - describe "CSV" do - it "uses the CSV exporter" do - export_data = double + describe "CSV" do + it "uses the CSV exporter" do + export_data = double - expect(Decidim::Exporters::CSV) - .to(receive(:new).with(anything, Decidim::Proposals::ProposalSerializer)) - .and_return(double(export: export_data)) + expect(Decidim::Exporters::CSV) + .to(receive(:new).with(anything, Decidim::Proposals::ProposalSerializer)) + .and_return(double(export: export_data)) - expect(ExportMailer) - .to(receive(:export).with(user, anything, export_data)) - .and_return(double(deliver_now: true)) + expect(ExportMailer) + .to(receive(:export).with(user, anything, export_data)) + .and_return(double(deliver_now: true)) - ExportJob.perform_now(user, component, "proposals", "CSV") + ExportJob.perform_now(user, component, "proposals", "CSV") + end end - end - describe "JSON" do - it "uses the JSON exporter" do - export_data = double + describe "JSON" do + it "uses the JSON exporter" do + export_data = double - expect(Decidim::Exporters::JSON) - .to(receive(:new).with(anything, Decidim::Proposals::ProposalSerializer)) - .and_return(double(export: export_data)) + expect(Decidim::Exporters::JSON) + .to(receive(:new).with(anything, Decidim::Proposals::ProposalSerializer)) + .and_return(double(export: export_data)) - expect(ExportMailer) - .to(receive(:export).with(user, anything, export_data)) - .and_return(double(deliver_now: true)) + expect(ExportMailer) + .to(receive(:export).with(user, anything, export_data)) + .and_return(double(deliver_now: true)) - ExportJob.perform_now(user, component, "proposals", "JSON") + ExportJob.perform_now(user, component, "proposals", "JSON") + end end - end - describe "Admin export" do - let(:serializer) { Decidim::Proposals::ProposalSerializer } + describe "Admin export" do + let(:serializer) { Decidim::Proposals::ProposalSerializer } + + before do + allow(Decidim::Exporters::CSV) + .to(receive(:new).with(anything, serializer)) + .and_return(double(export: "normal export data")) + end + + it "allows admin to access admin_export" do + expect(Decidim::Exporters::CSV) + .to(receive(:new).with(anything, serializer)) + .and_return(double(admin_export: "admin export data")) + + expect(ExportMailer) + .to(receive(:export).with(admin, anything, "admin export data")) + .and_return(double(deliver_now: true)) + + ExportJob.perform_now(admin, component, "proposals", "CSV") + end + + it "allows admin of the process to access admin_export" do + expect(Decidim::Exporters::CSV) + .to(receive(:new).with(anything, serializer)) + .and_return(double(admin_export: "admin export data")) + expect(ExportMailer) + .to(receive(:export).with(admin_of_the_process, anything, "admin export data")) + .and_return(double(deliver_now: true)) + + ExportJob.perform_now(admin_of_the_process, component, "proposals", "CSV") + end + + it "does not allow normal user to access admin_export" do + expect(Decidim::Exporters::CSV) + .to(receive(:new).with(anything, serializer)) + .and_return(double(export: "normal export data")) + + expect(ExportMailer) + .to(receive(:export).with(user, anything, "normal export data")) + .and_return(double(deliver_now: true)) + + ExportJob.perform_now(user, component, "proposals", "CSV") + end + end + end + + describe "export for assemblies" do before do - allow(Decidim::Exporters::CSV) - .to(receive(:new).with(anything, serializer)) - .and_return(double(export: "normal export data")) + component.update!(participatory_space: assembly) + create(:assembly_user_role, user: admin_of_the_assembly, assembly: assembly, role: "admin") + + allow(component.manifest).to receive(:export_manifests).and_return([export_manifest]) end - it "allows admin to access admin_export" do - expect(Decidim::Exporters::CSV) - .to(receive(:new).with(anything, serializer)) - .and_return(double(admin_export: "admin export data")) + it "sends an email with the result of the export" do + ExportJob.perform_now(user, component, "proposals", "CSV") - expect(ExportMailer) - .to(receive(:export).with(admin, anything, "admin export data")) - .and_return(double(deliver_now: true)) + email = last_email + expect(email.subject).to include("proposals") + attachment = email.attachments.first - ExportJob.perform_now(admin, component, "proposals", "CSV") + expect(attachment.read.length).to be_positive + expect(attachment.mime_type).to eq("application/zip") + expect(attachment.filename).to match(/^proposals-[0-9]+-[0-9]+-[0-9]+-[0-9]+\.zip$/) end - it "allows admin of the process to access admin_export" do - expect(Decidim::Exporters::CSV) - .to(receive(:new).with(anything, serializer)) - .and_return(double(admin_export: "admin export data")) + describe "admin export" do + let(:serializer) { Decidim::Proposals::ProposalSerializer } - expect(ExportMailer) - .to(receive(:export).with(admin_of_the_process, anything, "admin export data")) - .and_return(double(deliver_now: true)) + before do + allow(Decidim::Exporters::CSV) + .to(receive(:new).with(anything, serializer)) + .and_return(double(export: "normal export data")) + end - ExportJob.perform_now(admin_of_the_process, component, "proposals", "CSV") - end + it "allows admin to access admin_export" do + expect(Decidim::Exporters::CSV) + .to(receive(:new).with(anything, serializer)) + .and_return(double(admin_export: "admin export data")) - it "does not allow normal user to access admin_export" do - expect(Decidim::Exporters::CSV) - .to(receive(:new).with(anything, serializer)) - .and_return(double(export: "normal export data")) + expect(ExportMailer) + .to(receive(:export).with(admin, anything, "admin export data")) + .and_return(double(deliver_now: true)) - expect(ExportMailer) - .to(receive(:export).with(user, anything, "normal export data")) - .and_return(double(deliver_now: true)) + ExportJob.perform_now(admin, component, "proposals", "CSV") + end - ExportJob.perform_now(user, component, "proposals", "CSV") + it "allows admin of the assembly to access admin_export" do + expect(Decidim::Exporters::CSV) + .to(receive(:new).with(anything, serializer)) + .and_return(double(admin_export: "admin export data")) + + expect(ExportMailer) + .to(receive(:export).with(admin_of_the_assembly, anything, "admin export data")) + .and_return(double(deliver_now: true)) + + ExportJob.perform_now(admin_of_the_assembly, component, "proposals", "CSV") + end end end end From ac0f749f704d390116f863c6173a53b1c6b947c1 Mon Sep 17 00:00:00 2001 From: Guillaume MORET <90462045+AyakorK@users.noreply.github.com> Date: Tue, 18 Feb 2025 10:02:10 +0100 Subject: [PATCH 05/19] chore: Retrieve Bordeaux to Decidim-App (#670) * chore: Retrieve SSO changes from BDX to the Dcd-app * fix: Update artifacts to v4 * lint: Global refactor of the extends to make it more readable * chore: Remove useless extend that is not currently used * fix: Add the skip_first_login_authorization? check to omniauth registrations * fix: skip email reconformation when changes comes from SSO * fix(sso): backport logout phae for openid connect * fix(tests): normalize i18n * fix(test): create dummy strategy for tests --------- Co-authored-by: moustachu --- .env-example | 3 + Gemfile | 2 +- Gemfile.lock | 51 ++-- app/helpers/application_helper.rb | 6 + ...mniauth_synced_email_field.html.erb.deface | 2 + ...omniauth_synced_name_field.html.erb.deface | 2 + ...auth_synced_nickname_field.html.erb.deface | 2 + ...auth_synced_profile_helper.html.erb.deface | 2 + .../_omniauth_synced_profile_helper.html.erb | 6 + config/application.rb | 4 + config/initializers/extends.rb | 3 +- config/initializers/omniauth.rb | 22 ++ .../initializers/omniauth_openid_connect.rb | 2 + config/locales/en.yml | 18 ++ config/locales/fr.yml | 18 ++ config/routes.rb | 5 + config/secrets.yml | 4 + .../create_omniauth_registration_extends.rb | 37 +++ .../decidim/account_controller_extends.rb | 78 ++++++ .../devise/account_controller_extends.rb | 37 --- ...niauth_registrations_controller_extends.rb | 36 +++ .../devise/sessions_controller_extends.rb | 42 +++- .../decidim/devise_controllers_extends.rb | 12 + .../strategies/openid_connect_extends.rb | 53 ++++ .../create_omniauth_registration_spec.rb | 231 ++++++++++++++++++ spec/controllers/account_controller_spec.rb | 101 ++++++++ .../omniauth_registrations_controller_spec.rb | 209 ++++++++++++++++ 27 files changed, 919 insertions(+), 69 deletions(-) create mode 100644 app/overrides/decidim/account/show/disabled_omniauth_synced_email_field.html.erb.deface create mode 100644 app/overrides/decidim/account/show/disabled_omniauth_synced_name_field.html.erb.deface create mode 100644 app/overrides/decidim/account/show/disabled_omniauth_synced_nickname_field.html.erb.deface create mode 100644 app/overrides/decidim/account/show/omniauth_synced_profile_helper.html.erb.deface create mode 100644 app/views/decidim/account/_omniauth_synced_profile_helper.html.erb create mode 100644 config/initializers/omniauth.rb create mode 100644 lib/extends/commands/decidim/create_omniauth_registration_extends.rb create mode 100644 lib/extends/controllers/decidim/account_controller_extends.rb delete mode 100644 lib/extends/controllers/decidim/devise/account_controller_extends.rb create mode 100644 lib/extends/controllers/decidim/devise/omniauth_registrations_controller_extends.rb create mode 100644 lib/extends/controllers/decidim/devise_controllers_extends.rb create mode 100644 lib/extends/omniauth/strategies/openid_connect_extends.rb create mode 100644 spec/commands/decidim/create_omniauth_registration_spec.rb create mode 100644 spec/controllers/account_controller_spec.rb create mode 100644 spec/controllers/omniauth_registrations_controller_spec.rb diff --git a/.env-example b/.env-example index 18d2e784f8..bca6c3d459 100644 --- a/.env-example +++ b/.env-example @@ -106,6 +106,9 @@ SMS_GATEWAY_MB_ACCOUNT_ID= # Format : comma separated list of auhtorization handler names # AUTO_EXPORT_AUTHORIZATIONS_DATA_TO_USER_DATA_ENABLED_FOR="authorization1,authorization2" +# Force profile sync on every omniauth connection (default: false) +# FORCE_PROFILE_SYNC_ON_OMNIAUTH_CONNECTION=false + # Delay until a user is considered inactive and receive a warning email (in days, default: 365) # DECIDIM_CLEANER_INACTIVE_USERS_MAIL= diff --git a/Gemfile b/Gemfile index dbd5d63799..c1e84b2ba8 100644 --- a/Gemfile +++ b/Gemfile @@ -42,7 +42,7 @@ gem "decidim-term_customizer", git: "https://github.com/OpenSourcePolitics/decid gem "decidim-guest_meeting_registration", git: "https://github.com/alecslupu-pfa/guest-meeting-registration.git", branch: DECIDIM_BRANCH # Omniauth gems -gem "omniauth-france_connect", git: "https://github.com/OpenSourcePolitics/omniauth-france_connect" +gem "omniauth-france_connect", git: "https://github.com/OpenSourcePolitics/omniauth-france_connect", branch: "feat/omniauth_openid_connect--v0.7.1" gem "omniauth_openid_connect" gem "omniauth-publik", git: "https://github.com/OpenSourcePolitics/omniauth-publik" diff --git a/Gemfile.lock b/Gemfile.lock index 47d849123e..d426c39539 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -42,7 +42,7 @@ GIT GIT remote: https://github.com/OpenSourcePolitics/decidim-module-gallery.git - revision: 726fab33984c3adeec30cf90d0e1b4ad3881787d + revision: 0ce98eeade3f86055782522ab9aa0339183eaa6e branch: fix/nokogiri_deps specs: decidim-gallery (0.26.0) @@ -72,7 +72,7 @@ GIT GIT remote: https://github.com/OpenSourcePolitics/decidim-module-ptp.git - revision: 32b0f9a29499768cf6783a0026badaed33f025ab + revision: 46bb834a68d52cd7d1d4bba6182cca55f2495125 specs: decidim-budgets_booth (0.27.0) decidim-budgets (~> 0.27.0) @@ -124,10 +124,11 @@ GIT GIT remote: https://github.com/OpenSourcePolitics/omniauth-france_connect - revision: 14a53ad31928c8a83742360cfbdb90938d0a057e + revision: cbf54f82e0ea55e7397004aa21905dce2b528674 + branch: feat/omniauth_openid_connect--v0.7.1 specs: omniauth-france_connect (0.1.0) - omniauth_openid_connect (~> 0.4.0) + omniauth_openid_connect (~> 0.7.0) GIT remote: https://github.com/OpenSourcePolitics/omniauth-publik @@ -612,6 +613,8 @@ GEM dotenv (= 2.8.1) railties (>= 3.2) dumb_delegator (1.0.0) + email_validator (2.2.4) + activemodel erb_lint (0.0.37) activesupport better_html (~> 1.0.7) @@ -694,7 +697,6 @@ GEM nokogiri (>= 1.4) html_tokenizer (0.0.7) htmlentities (4.3.4) - httpclient (2.8.3) i18n (1.14.5) concurrent-ruby (~> 1.0) i18n-tasks (0.9.37) @@ -846,21 +848,22 @@ GEM omniauth-twitter (1.4.0) omniauth-oauth (~> 1.1) rack - omniauth_openid_connect (0.4.0) - addressable (~> 2.5) + omniauth_openid_connect (0.7.1) omniauth (>= 1.9, < 3) - openid_connect (~> 1.1) - openid_connect (1.4.2) + openid_connect (~> 2.2) + openid_connect (2.3.1) activemodel attr_required (>= 1.0.0) - json-jwt (>= 1.15.0) - net-smtp - rack-oauth2 (~> 1.21) - swd (~> 1.3) + email_validator + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.16) + mail + rack-oauth2 (~> 2.2) + swd (~> 2.0) tzinfo - validate_email validate_url - webfinger (~> 1.2) + webfinger (~> 2.0) origami (2.1.0) colorize (~> 0.7) orm_adapter (0.5.0) @@ -896,10 +899,11 @@ GEM rack (>= 1.0, < 4) rack-cors (1.1.1) rack (>= 2.0.0) - rack-oauth2 (1.21.3) + rack-oauth2 (2.2.1) activesupport attr_required - httpclient + faraday (~> 2.0) + faraday-follow_redirects json-jwt (>= 1.11.0) rack (>= 2.1.0) rack-protection (3.2.0) @@ -1079,10 +1083,11 @@ GEM activesupport (>= 5.2) sprockets (>= 3.0.0) ssrf_filter (1.1.2) - swd (1.3.0) + swd (2.0.3) activesupport (>= 3) attr_required (>= 0.0.5) - httpclient (>= 2.4) + faraday (~> 2.0) + faraday-follow_redirects sys-filesystem (1.4.4) ffi (~> 1.1) temple (0.10.3) @@ -1102,9 +1107,6 @@ GEM valid_email2 (2.3.1) activemodel (>= 3.2) mail (~> 2.5) - validate_email (0.1.6) - activemodel (>= 3.0) - mail (>= 2.2.5) validate_url (1.0.15) activemodel (>= 3.0.0) public_suffix @@ -1128,9 +1130,10 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) - webfinger (1.2.0) + webfinger (2.1.3) activesupport - httpclient (>= 2.4) + faraday (~> 2.0) + faraday-follow_redirects webmock (3.22.0) addressable (>= 2.8.0) crack (>= 0.3.2) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index a6ca5abc54..8b30c67c07 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -27,4 +27,10 @@ def sso_provider_button(provider, link_to_path) html_element end end + + def force_profile_sync_on_omniauth_connection? + !current_organization.sign_in_enabled? && + current_organization.enabled_omniauth_providers.any? && + Rails.application.secrets.dig(:decidim, :omniauth, :force_profile_sync_on_omniauth_connection) + end end diff --git a/app/overrides/decidim/account/show/disabled_omniauth_synced_email_field.html.erb.deface b/app/overrides/decidim/account/show/disabled_omniauth_synced_email_field.html.erb.deface new file mode 100644 index 0000000000..8ab2f188ea --- /dev/null +++ b/app/overrides/decidim/account/show/disabled_omniauth_synced_email_field.html.erb.deface @@ -0,0 +1,2 @@ + + <%= f.email_field :email, disabled: force_profile_sync_on_omniauth_connection? || current_user.unconfirmed_email.present?, autocomplete: "email" %> \ No newline at end of file diff --git a/app/overrides/decidim/account/show/disabled_omniauth_synced_name_field.html.erb.deface b/app/overrides/decidim/account/show/disabled_omniauth_synced_name_field.html.erb.deface new file mode 100644 index 0000000000..fd7b650b6b --- /dev/null +++ b/app/overrides/decidim/account/show/disabled_omniauth_synced_name_field.html.erb.deface @@ -0,0 +1,2 @@ + + <%= f.text_field :name, disabled: force_profile_sync_on_omniauth_connection?, autocomplete: "name" %> \ No newline at end of file diff --git a/app/overrides/decidim/account/show/disabled_omniauth_synced_nickname_field.html.erb.deface b/app/overrides/decidim/account/show/disabled_omniauth_synced_nickname_field.html.erb.deface new file mode 100644 index 0000000000..70757c5b23 --- /dev/null +++ b/app/overrides/decidim/account/show/disabled_omniauth_synced_nickname_field.html.erb.deface @@ -0,0 +1,2 @@ + + <%= f.text_field :nickname, disabled: force_profile_sync_on_omniauth_connection?, autocomplete: "nickname" %> \ No newline at end of file diff --git a/app/overrides/decidim/account/show/omniauth_synced_profile_helper.html.erb.deface b/app/overrides/decidim/account/show/omniauth_synced_profile_helper.html.erb.deface new file mode 100644 index 0000000000..fb5cc68635 --- /dev/null +++ b/app/overrides/decidim/account/show/omniauth_synced_profile_helper.html.erb.deface @@ -0,0 +1,2 @@ + + <%= render partial: "/decidim/account/omniauth_synced_profile_helper", locals: { f: f } %> diff --git a/app/views/decidim/account/_omniauth_synced_profile_helper.html.erb b/app/views/decidim/account/_omniauth_synced_profile_helper.html.erb new file mode 100644 index 0000000000..544f98253f --- /dev/null +++ b/app/views/decidim/account/_omniauth_synced_profile_helper.html.erb @@ -0,0 +1,6 @@ +<% if force_profile_sync_on_omniauth_connection? %> +
+

<%= t("decidim.account.omniauth_synced_profile.helper.title") %>

+ <%= t("decidim.account.omniauth_synced_profile.helper.body_html") %> +
+<% end %> diff --git a/config/application.rb b/config/application.rb index 759eb48b0d..b9ce3a6c87 100644 --- a/config/application.rb +++ b/config/application.rb @@ -47,7 +47,9 @@ class Application < Rails::Application config.after_initialize do # Controllers + require "extends/controllers/decidim/devise_controllers_extends" require "extends/controllers/decidim/devise/sessions_controller_extends" + require "extends/controllers/decidim/devise/omniauth_registrations_controller_extends" require "extends/controllers/decidim/editor_images_controller_extends" require "extends/controllers/decidim/proposals/proposals_controller_extends" require "extends/controllers/decidim/newsletters_controller_extends" @@ -55,6 +57,7 @@ class Application < Rails::Application require "extends/controllers/decidim/scopes_controller_extends" require "extends/controllers/decidim/initiatives/committee_requests_controller_extends" require "extends/controllers/decidim/comments/comments_controller" + require "extends/controllers/decidim/account_controller_extends" # Models require "extends/models/decidim/budgets/project_extends" require "extends/models/decidim/authorization_extends" @@ -74,6 +77,7 @@ class Application < Rails::Application require "extends/commands/decidim/budgets/admin/import_proposals_to_budgets_extends" require "extends/commands/decidim/admin/destroy_participatory_space_private_user_extends" require "extends/commands/decidim/admin/create_attachment_extends" + require "extends/commands/decidim/create_omniauth_registration_extends" Decidim::GraphiQL::Rails.config.tap do |config| config.initial_query = "{\n deployment {\n version\n branch\n remote\n upToDate\n currentCommit\n latestCommit\n locallyModified\n }\n}".html_safe diff --git a/config/initializers/extends.rb b/config/initializers/extends.rb index 451ff4a9d6..262a37aa33 100644 --- a/config/initializers/extends.rb +++ b/config/initializers/extends.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require "extends/controllers/decidim/devise/account_controller_extends" require "extends/cells/decidim/content_blocks/hero_cell_extends" require "extends/uploaders/decidim/application_uploader_extends" require "extends/lib/decidim/proposals/imports/proposal_answer_creator_extends" @@ -9,3 +8,5 @@ require "decidim/exporters/serializer" require "extends/lib/decidim/forms/user_answers_serializer_extend" require "extends/lib/decidim/geocoding/geocoder_coordinates_extends" + +require "extends/omniauth/strategies/openid_connect_extends" diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb new file mode 100644 index 0000000000..42a66ce701 --- /dev/null +++ b/config/initializers/omniauth.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +ActiveSupport::Notifications.subscribe "decidim.user.omniauth_registration" do |_name, data| + Rails.logger.debug "decidim.user.omniauth_registration event in config/initializers/omniauth.rb" + + next unless Rails.application.secrets.dig(:decidim, :omniauth, :force_profile_sync_on_omniauth_connection) + + Rails.logger.debug "decidim.user.omniauth_registration :: force_profile_sync_on_omniauth_connection is enabled" + + update_user_profile(data) +end + +def update_user_profile(data) + user = Decidim::User.find(data[:user_id]) + + user.email = data[:email] if data[:email].present? + user.skip_reconfirmation! if data[:email].present? && user.email_changed? + user.name = data[:name] if data[:name].present? + user.nickname = data[:nickname] if data[:nickname].present? && data.dig(:raw_data, :info, "nickname") != user.nickname + + user.save!(validate: false, touch: false) +end diff --git a/config/initializers/omniauth_openid_connect.rb b/config/initializers/omniauth_openid_connect.rb index bbd2807de2..1ee89f3469 100644 --- a/config/initializers/omniauth_openid_connect.rb +++ b/config/initializers/omniauth_openid_connect.rb @@ -31,6 +31,8 @@ %w( issuer response_type + logout_policy + logout_path post_logout_redirect_uri uid_field ).map(&:to_sym).each do |key| diff --git a/config/locales/en.yml b/config/locales/en.yml index ffa0ec09a7..4f057d141d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -15,6 +15,22 @@ en: participatory_space_private_user_csv_import: file: importing file decidim: + account: + omniauth_synced_profile: + helper: + body_html: |- +

+ The following informations are synchronized with an external identity provider: +

+ +

+ You can't edit these informations here. +

+ title: Profile synchronization admin: actions: add: Add @@ -271,6 +287,8 @@ en: client_options_secret: Client secret discovery: Enable discovery (true or false) issuer: Issuer (Identity Provider) + logout_path: Logout path (with starting "/") + logout_policy: Logout policy (none|session.destroy) post_logout_redirect_uri: Post logout redirect URI response_type: Response type scope: Scope diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 8de8cca9e5..1a31e6e320 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -15,6 +15,22 @@ fr: participatory_space_private_user_csv_import: file: importer un fichier d'utilisateurs decidim: + account: + omniauth_synced_profile: + helper: + body_html: |- +

+ les informations suivantes sont synchronisées avec un fournisseur d'identité externe: +

+ +

+ Vous ne pouvez pas modifier ces informations ici. +

+ title: Synchronisation du profil admin: actions: add: Ajouter @@ -273,6 +289,8 @@ fr: client_options_secret: Client secret discovery: Enable discovery (true or false) issuer: Issuer (Identity Provider) + logout_path: Logout path (with starting "/") + logout_policy: Logout policy (none|session.destroy) post_logout_redirect_uri: Post logout redirect URI response_type: Response type scope: Scope diff --git a/config/routes.rb b/config/routes.rb index a87f61ad60..d2cfbc34d9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -10,6 +10,11 @@ mount LetterOpenerWeb::Engine, at: "/letter_opener" if Rails.env.development? || ENV.fetch("ENABLE_LETTER_OPENER", "0") == "1" + devise_scope :user do + get "users/sign_out", + to: "decidim/devise/sessions#destroy" + end + mount Decidim::Core::Engine => "/" # mount Decidim::Map::Engine => '/map' # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html diff --git a/config/secrets.yml b/config/secrets.yml index 905309cdbc..6a462c2895 100644 --- a/config/secrets.yml +++ b/config/secrets.yml @@ -42,6 +42,8 @@ default: &default max_time_in_validating_state: <%= ENV.fetch("INITIATIVES_MAX_TIME_IN_VALIDATING_STATE", 60).to_i %> print_enabled: <%= ENV.fetch("INITIATIVES_PRINT_ENABLED", "auto").to_s %> do_not_require_authorization: <%= ENV.fetch("INITIATIVES_DO_NOT_REQUIRE_AUTHORIZATION", "auto").to_s %> + omniauth: + force_profile_sync_on_omniauth_connection: <%= ENV.fetch("FORCE_PROFILE_SYNC_ON_OMNIAUTH_CONNECTION", "false") == "true" %> rack_attack: enabled: <%= ENV["ENABLE_RACK_ATTACK"] %> fail2ban: @@ -109,6 +111,8 @@ default: &default client_options_redirect_uri: <%= ENV["OMNIAUTH_OPENID_CONNECT_CLIENT_OPTIONS_REDIRECT_URI"] %> scope: <%= ENV["OMNIAUTH_OPENID_CONNECT_SCOPE"] %> response_type: <%= ENV["OMNIAUTH_OPENID_CONNECT_RESPONSE_TYPE"] %> + logout_policy: <%= ENV["OMNIAUTH_OPENID_CONNECT_LOGOUT_POLICY"] %> + logout_path: <%= ENV["OMNIAUTH_OPENID_CONNECT_LOGOUT_PATH"] %> post_logout_redirect_uri: <%= ENV["OMNIAUTH_OPENID_CONNECT_POST_LOGOUT_REDIRECT_URI"] %> uid_field: <%= ENV["OMNIAUTH_OPENID_CONNECT_UID_FIELD"] %> maps: diff --git a/lib/extends/commands/decidim/create_omniauth_registration_extends.rb b/lib/extends/commands/decidim/create_omniauth_registration_extends.rb new file mode 100644 index 0000000000..ee2a96a175 --- /dev/null +++ b/lib/extends/commands/decidim/create_omniauth_registration_extends.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module CreateOmniauthRegistrationExtends + # Executes the command. Broadcasts these events: + # + # - :ok when everything is valid. + # - :invalid if the form wasn't valid and we couldn't proceed. + # + # Returns nothing. + def call + Rails.logger.debug("Decidim::CreateOmniauthRegistrationExtends.call") + verify_oauth_signature! + begin + if existing_identity + @identity = existing_identity + @user = @identity.user + verify_user_confirmed(@user) + trigger_omniauth_registration + return broadcast(:ok, @user) + end + return broadcast(:invalid) if form.invalid? + + transaction do + create_or_find_user + @identity = create_identity + end + trigger_omniauth_registration + broadcast(:ok, @user) + rescue ActiveRecord::RecordInvalid => e + broadcast(:error, e.record) + end + end +end + +Decidim::CreateOmniauthRegistration.class_eval do + prepend(CreateOmniauthRegistrationExtends) +end diff --git a/lib/extends/controllers/decidim/account_controller_extends.rb b/lib/extends/controllers/decidim/account_controller_extends.rb new file mode 100644 index 0000000000..ee0a4f980b --- /dev/null +++ b/lib/extends/controllers/decidim/account_controller_extends.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Decidim + module AccountControllerExtends + def destroy + enforce_permission_to :delete, :user, current_user: current_user + @form = form(Decidim::DeleteAccountForm).from_params(params) + Decidim::DestroyAccount.call(current_user, @form) do + on(:ok) do + handle_successful_destruction + end + on(:invalid) do + handle_invalid_destruction + end + end + end + + private + + def handle_successful_destruction + sign_out(current_user) + flash[:notice] = t("account.destroy.success", scope: "decidim") + handle_omniauth_logout if active_omniauth_session? + + handle_france_connect_logout if active_france_connect_session? + end + + def handle_omniauth_logout + provider = session.delete("omniauth.provider") + logout_policy = session.delete("omniauth.#{provider}.logout_policy") + logout_path = session.delete("omniauth.#{provider}.logout_path") + + redirect_to omniauth_logout_path(provider, logout_path) if provider.present? && logout_policy == "session.destroy" && logout_path.present? + end + + def handle_france_connect_logout + destroy_france_connect_session(session["omniauth.france_connect.end_session_uri"]) + end + + def handle_invalid_destruction + flash[:alert] = t("account.destroy.error", scope: "decidim") + redirect_to decidim.root_path + end + + def account_params + if force_profile_sync_on_omniauth_connection? + params[:user][:name] = current_user.name + params[:user][:email] = current_user.email + params[:user][:nickname] = current_user.nickname + end + params[:user].to_unsafe_h + end + + def destroy_france_connect_session(fc_logout_path) + session.delete("omniauth.france_connect.end_session_uri") + redirect_to fc_logout_path + end + + def active_france_connect_session? + current_organization.enabled_omniauth_providers.include?(:france_connect) && session["omniauth.france_connect.end_session_uri"].present? + end + + def active_omniauth_session? + session["omniauth.provider"].present? + end + + def omniauth_logout_path(provider, logout_path) + uri = URI.parse(decidim.send("user_#{provider}_omniauth_authorize_path")) + uri.path += logout_path + uri.to_s + end + end +end + +Decidim::AccountController.class_eval do + prepend(Decidim::AccountControllerExtends) + include ApplicationHelper +end diff --git a/lib/extends/controllers/decidim/devise/account_controller_extends.rb b/lib/extends/controllers/decidim/devise/account_controller_extends.rb deleted file mode 100644 index bbf50fa7e0..0000000000 --- a/lib/extends/controllers/decidim/devise/account_controller_extends.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -module AccountControllerExtends - def destroy - enforce_permission_to :delete, :user, current_user: current_user - @form = form(Decidim::DeleteAccountForm).from_params(params) - - Decidim::DestroyAccount.call(current_user, @form) do - on(:ok) do - sign_out(current_user) - destroy_france_connect_session(session["omniauth.france_connect.end_session_uri"]) if active_france_connect_session? - flash[:notice] = t("account.destroy.success", scope: "decidim") - end - - on(:invalid) do - flash[:alert] = t("account.destroy.error", scope: "decidim") - redirect_to decidim.root_path - end - end - end - - private - - def destroy_france_connect_session(fc_logout_path) - session.delete("omniauth.france_connect.end_session_uri") - - redirect_to fc_logout_path - end - - def active_france_connect_session? - current_organization.enabled_omniauth_providers.include?(:france_connect) && session["omniauth.france_connect.end_session_uri"].present? - end -end - -Decidim::AccountController.class_eval do - prepend(AccountControllerExtends) -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 new file mode 100644 index 0000000000..66682ee8e1 --- /dev/null +++ b/lib/extends/controllers/decidim/devise/omniauth_registrations_controller_extends.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module OmniauthRegistrationsControllerExtends + extend ActiveSupport::Concern + + included do + def sign_in_and_redirect(resource_or_scope, *args) + strategy = request.env["omniauth.strategy"] + provider = strategy.name + session["omniauth.provider"] = provider + session["omniauth.#{provider}.logout_policy"] = strategy.options[:logout_policy] if strategy.options[:logout_policy].present? + session["omniauth.#{provider}.logout_path"] = strategy.options[:logout_path] if strategy.options[:logout_path].present? + super + end + + def after_sign_in_path_for(user) + if user.present? && user.blocked? + check_user_block_status(user) + elsif !skip_first_login_authorization? && (first_login_and_not_authorized?(user) && !user.admin? && !pending_redirect?(user)) + decidim_verifications.first_login_authorizations_path + else + super + end + end + + private + + def skip_first_login_authorization? + ActiveRecord::Type::Boolean.new.cast(ENV.fetch("SKIP_FIRST_LOGIN_AUTHORIZATION", "false")) + end + end +end + +Decidim::Devise::OmniauthRegistrationsController.class_eval do + include(OmniauthRegistrationsControllerExtends) +end diff --git a/lib/extends/controllers/decidim/devise/sessions_controller_extends.rb b/lib/extends/controllers/decidim/devise/sessions_controller_extends.rb index c7ec5047e5..cbf9040d9d 100644 --- a/lib/extends/controllers/decidim/devise/sessions_controller_extends.rb +++ b/lib/extends/controllers/decidim/devise/sessions_controller_extends.rb @@ -4,16 +4,32 @@ module SessionControllerExtends extend ActiveSupport::Concern included do + # rubocop:disable Metrics/PerceivedComplexity def destroy - current_user.invalidate_all_sessions! - if active_france_connect_session? - destroy_france_connect_session(session["omniauth.france_connect.end_session_uri"]) - elsif params[:translation_suffix].present? - super { set_flash_message! :notice, params[:translation_suffix], { scope: "decidim.devise.sessions" } } + if active_omniauth_session? + provider = session.delete("omniauth.provider") + logout_policy = session.delete("omniauth.#{provider}.logout_policy") + logout_path = session.delete("omniauth.#{provider}.logout_path") + end + + if provider.present? && logout_policy == "session.destroy" && logout_path.present? + redirect_to omniauth_logout_path(provider, logout_path) else - super + if current_user + current_user.invalidate_all_sessions! + request.params[stored_location_key_for(current_user)] = stored_location_for(current_user) if pending_redirect?(current_user) + end + + if active_france_connect_session? + destroy_france_connect_session(session["omniauth.france_connect.end_session_uri"]) + elsif params[:translation_suffix].present? + super { set_flash_message! :notice, params[:translation_suffix], { scope: "decidim.devise.sessions" } } + else + super + end end end + # rubocop:enable Metrics/PerceivedComplexity def after_sign_in_path_for(user) if user.present? && user.blocked? @@ -25,6 +41,10 @@ def after_sign_in_path_for(user) end end + def after_sign_out_path_for(user) + request.params[stored_location_key_for(user)] || request.referer || super + end + private # Skip authorization handler by default @@ -46,6 +66,16 @@ def destroy_france_connect_session(fc_logout_path) def active_france_connect_session? current_organization.enabled_omniauth_providers.include?(:france_connect) && session["omniauth.france_connect.end_session_uri"].present? end + + def active_omniauth_session? + session["omniauth.provider"].present? + end + + def omniauth_logout_path(provider, logout_path) + uri = URI.parse(decidim.send("user_#{provider}_omniauth_authorize_path")) + uri.path += logout_path + uri.to_s + end end Decidim::Devise::SessionsController.class_eval do diff --git a/lib/extends/controllers/decidim/devise_controllers_extends.rb b/lib/extends/controllers/decidim/devise_controllers_extends.rb new file mode 100644 index 0000000000..12649b7da6 --- /dev/null +++ b/lib/extends/controllers/decidim/devise_controllers_extends.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module DeviseControllersExtends + # Skip authorization handler by default + def skip_first_login_authorization? + ActiveRecord::Type::Boolean.new.cast(ENV.fetch("SKIP_FIRST_LOGIN_AUTHORIZATION", "false")) + end +end + +Decidim::DeviseControllers.module_eval do + prepend(DeviseControllersExtends) +end diff --git a/lib/extends/omniauth/strategies/openid_connect_extends.rb b/lib/extends/omniauth/strategies/openid_connect_extends.rb new file mode 100644 index 0000000000..56b8330e51 --- /dev/null +++ b/lib/extends/omniauth/strategies/openid_connect_extends.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module OpenIDConnectExtends + extend ActiveSupport::Concern + + included do + include Rails.application.routes.mounted_helpers + + option :logout_policy, "none" + + def other_phase + if logout_path_pattern.match?(current_path) + if end_session_callback? + log :debug, "Logout phase callback." + session.delete("omniauth.logout.callback") + return redirect(decidim.destroy_user_session_path) + else + log :debug, "Logout phase initiated." + @env["omniauth.strategy"] ||= self + setup_phase + options.issuer = issuer if options.issuer.to_s.empty? + discover! + session["omniauth.logout.callback"] = end_session_callback_value + return redirect(end_session_uri) if end_session_uri + end + end + call_app! + end + + def end_session_uri + return unless end_session_endpoint_is_valid? + + end_session_uri = URI(client_options.end_session_endpoint) + end_session_uri.query = URI.encode_www_form( + id_token_hint: credentials[:id_token], + post_logout_redirect_uri: options.post_logout_redirect_uri + ) + end_session_uri.to_s + end + + def end_session_callback? + session["omniauth.logout.callback"] == end_session_callback_value + end + + def end_session_callback_value + "#{name}--#{session["session_id"]}" + end + end +end + +OmniAuth::Strategies::OpenIDConnect.class_eval do + include(OpenIDConnectExtends) +end diff --git a/spec/commands/decidim/create_omniauth_registration_spec.rb b/spec/commands/decidim/create_omniauth_registration_spec.rb new file mode 100644 index 0000000000..4f5467e7b9 --- /dev/null +++ b/spec/commands/decidim/create_omniauth_registration_spec.rb @@ -0,0 +1,231 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Comments + describe CreateOmniauthRegistration do + describe "call" do + let(:organization) { create(:organization) } + let(:email) { "user@from-facebook.com" } + let(:provider) { "facebook" } + let(:uid) { "12345" } + let(:oauth_signature) { OmniauthRegistrationForm.create_signature(provider, uid) } + let(:verified_email) { email } + let(:form_params) do + { + "user" => { + "provider" => provider, + "uid" => uid, + "email" => email, + "email_verified" => true, + "name" => "Facebook User", + "nickname" => "facebook_user", + "oauth_signature" => oauth_signature, + "avatar_url" => "http://www.example.com/foo.jpg" + } + } + end + let(:form) do + OmniauthRegistrationForm.from_params( + form_params + ).with_context( + current_organization: organization + ) + end + let(:command) { described_class.new(form, verified_email) } + + before do + stub_request(:get, "http://www.example.com/foo.jpg") + .to_return(status: 200, body: File.read("spec/test_assets/logo_asset.png"), headers: { "Content-Type" => "image/png" }) + end + + describe "when the form oauth_signature cannot be verified" do + let(:oauth_signature) { "1234" } + + it "raises a InvalidOauthSignature exception" do + expect { command.call }.to raise_error InvalidOauthSignature + end + end + + context "when the form is not valid" do + before do + allow(form).to receive(:invalid?).and_return(true) + end + + it "broadcasts invalid" do + expect { command.call }.to broadcast(:invalid) + end + + it "doesn't create a user" do + expect do + command.call + end.not_to change(User, :count) + end + end + + context "when the form is valid" do + it "broadcasts ok" do + expect { command.call }.to broadcast(:ok) + end + + it "creates a new user" do + allow(SecureRandom).to receive(:hex).and_return("decidim123456789") + + expect do + command.call + end.to change(User, :count).by(1) + + user = User.find_by(email: form.email) + expect(user.encrypted_password).not_to be_nil + expect(user.email).to eq(form.email) + expect(user.organization).to eq(organization) + expect(user.newsletter_notifications_at).to be_nil + expect(user).to be_confirmed + expect(user.valid_password?("decidim123456789")).to be(true) + end + + it "leaves password_updated_at nil" do + expect { command.call }.to broadcast(:ok) + + user = User.find_by(email: form.email) + expect(user.password_updated_at).to be_nil + end + + it "notifies about registration with oauth data" do + user = create(:user, email: email, organization: organization) + identity = Decidim::Identity.new(id: 1234) + allow(command).to receive(:create_identity).and_return(identity) + + expect(ActiveSupport::Notifications) + .to receive(:publish) + .with( + "decidim.user.omniauth_registration", + user_id: user.id, + identity_id: 1234, + provider: provider, + uid: uid, + email: email, + name: "Facebook User", + nickname: "facebook_user", + avatar_url: "http://www.example.com/foo.jpg", + raw_data: {} + ) + command.call + end + + describe "user linking" do + context "with a verified email" do + let(:verified_email) { email } + + it "links a previously existing user" do + user = create(:user, email: email, organization: organization) + expect { command.call }.not_to change(User, :count) + + expect(user.identities.length).to eq(1) + end + + it "confirms a previously existing user" do + create(:user, email: email, organization: organization) + expect { command.call }.not_to change(User, :count) + + user = User.find_by(email: email) + expect(user).to be_confirmed + end + end + + context "with an unverified email" do + let(:verified_email) { nil } + + it "doesn't link a previously existing user" do + user = create(:user, email: email, organization: organization) + expect { command.call }.to broadcast(:error) + + expect(user.identities.length).to eq(0) + end + + it "doesn't confirm a previously existing user" do + create(:user, email: email, organization: organization) + expect { command.call }.to broadcast(:error) + + user = User.find_by(email: email) + expect(user).not_to be_confirmed + end + end + end + + it "creates a new identity" do + expect do + command.call + end.to change(Identity, :count).by(1) + last_identity = Identity.last + expect(last_identity.provider).to eq(form.provider) + expect(last_identity.uid).to eq(form.uid) + expect(last_identity.organization).to eq(organization) + end + + it "confirms the user if the email is already verified" do + expect_any_instance_of(User).to receive(:skip_confirmation!) + command.call + end + end + + context "when a user exists with that identity" do + before do + user = create(:user, email: email, organization: organization) + create(:identity, user: user, provider: provider, uid: uid) + end + + it "broadcasts ok" do + expect { command.call }.to broadcast(:ok) + end + + context "with the same email as reported by the identity" do + it "confirms the user" do + command.call + + user = User.find_by(email: email) + expect(user).to be_confirmed + end + end + + context "with another email than in the one reported by the identity" do + let(:verified_email) { "other@email.com" } + + it "doesn't confirm the user" do + command.call + + user = User.find_by(email: email) + expect(user).not_to be_confirmed + end + end + end + + # New tests for triggering omniauth_registration: + context "when the user has an existing identity" do + before do + user = create(:user, email: email, organization: organization) + create(:identity, user: user, provider: provider, uid: uid) + allow(command).to receive(:trigger_omniauth_registration) + end + + it "triggers omniauth registration" do + expect(command).to receive(:trigger_omniauth_registration) + command.call + end + end + + context "when a new user and identity are created" do + before do + allow(command).to receive(:trigger_omniauth_registration) + end + + it "triggers omniauth registration" do + expect(command).to receive(:trigger_omniauth_registration) + command.call + end + end + end + end + end +end diff --git a/spec/controllers/account_controller_spec.rb b/spec/controllers/account_controller_spec.rb new file mode 100644 index 0000000000..158cda66e1 --- /dev/null +++ b/spec/controllers/account_controller_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + describe AccountController, type: :controller do + routes { Decidim::Core::Engine.routes } + + let(:user) { create(:user, :confirmed, organization: organization) } + let(:organization) { create(:organization) } + + before do + request.env["decidim.current_organization"] = organization + sign_in user + end + + describe "DELETE destroy" do + context "when FranceConnect is activated" do + let(:organization) { create(:organization, omniauth_settings: omniauth_settings) } + let(:omniauth_settings) do + { omniauth_settings_france_connect_enabled: true } + end + + before do + stub_request(:get, /test-france-connect.fr/) + .with(headers: { "Accept" => "*/*", "User-Agent" => "Ruby" }) + .to_return(status: 200, body: "", headers: {}) + + request.env["decidim.current_organization"] = user.organization + request.env["devise.mapping"] = ::Devise.mappings[:user] + + sign_in user + end + + it "logout user from France Connect and deletes the account" do + delete :destroy, session: { "omniauth.france_connect.end_session_uri" => "http://test-france-connect.fr/" } + + expect(controller.current_user).to be_nil + expect(controller).to redirect_to("http://test-france-connect.fr/") + expect(flash[:notice]).to eq("Your account was successfully deleted.") + end + + context "and France Connect logout session is not present" do + it "deletes the account" do + delete :destroy + + expect(controller.current_user).to be_nil + expect(controller).not_to redirect_to("http://test-france-connect.fr/") + expect(flash[:notice]).to eq("Your account was successfully deleted.") + end + end + end + + context "when another OmniAuth provider is activated" do + let(:organization) { create(:organization, omniauth_settings: omniauth_settings) } + let(:omniauth_settings) do + { omniauth_settings_facebook_enabled: true } + end + + before do + request.env["decidim.current_organization"] = user.organization + request.env["devise.mapping"] = ::Devise.mappings[:user] + + sign_in user + session["omniauth.provider"] = :facebook + session["omniauth.facebook.logout_policy"] = "session.destroy" + session["omniauth.facebook.logout_path"] = "/logout" + end + + it "logout user from OmniAuth provider and deletes the account" do + delete :destroy + + expect(controller.current_user).to be_nil + expect(controller).to redirect_to("http://test.host/users/auth/facebook/logout") + expect(flash[:notice]).to eq("Your account was successfully deleted.") + end + end + + context "when no OmniAuth provider is activated" do + it "deletes the account" do + delete :destroy + + expect(controller.current_user).to be_nil + expect(flash[:notice]).to eq("Your account was successfully deleted.") + end + end + + context "when account deletion fails" do + before do + allow_any_instance_of(Decidim::DestroyAccount).to receive(:call).and_return(:invalid) + end + + it "does not delete the account and shows an error message" do + delete :destroy + + expect(controller.current_user).not_to be_nil + end + end + end + end +end diff --git a/spec/controllers/omniauth_registrations_controller_spec.rb b/spec/controllers/omniauth_registrations_controller_spec.rb new file mode 100644 index 0000000000..5b14edccdb --- /dev/null +++ b/spec/controllers/omniauth_registrations_controller_spec.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + describe Decidim::Devise::OmniauthRegistrationsController, type: :controller do + routes { Decidim::Core::Engine.routes } + + let(:organization) { create(:organization) } + + before do + request.env["decidim.current_organization"] = organization + request.env["devise.mapping"] = ::Devise.mappings[:user] + end + + describe "POST create" do + let(:provider) { "facebook" } + let(:uid) { "12345" } + let(:email) { "user@from-facebook.com" } + let!(:user) { create(:user, organization: organization, email: email) } + + before do + request.env["omniauth.auth"] = { + provider: provider, + uid: uid, + info: { + name: "Facebook User", + nickname: "facebook_user", + email: email + } + } + request.env["omniauth.strategy"] = OmniAuth::Strategies::Facebook.new({}) + end + + describe "after_sign_in_path_for" do + subject { controller.after_sign_in_path_for(user) } + + before do + request.env["decidim.current_organization"] = user.organization + end + + context "when the given resource is a user" do + context "and is an admin" do + let(:user) { build(:user, :admin, sign_in_count: 1) } + + before do + controller.store_location_for(user, account_path) + end + + it { is_expected.to eq account_path } + end + + context "and is not an admin" do + context "when it is the first time to log in" do + let(:user) { build(:user, :confirmed, sign_in_count: 1) } + + context "when there are authorization handlers" do + before do + allow(user.organization).to receive(:available_authorizations) + .and_return(["dummy_authorization_handler"]) + end + + it { is_expected.to eq("/authorizations/first_login") } + + context "when there is a pending redirection" do + before do + controller.store_location_for(user, account_path) + end + + it { is_expected.to eq account_path } + end + + context "when the user has not confirmed their email" do + before do + user.confirmed_at = nil + end + + it { is_expected.to eq("/") } + end + + context "when the user is blocked" do + before do + user.blocked = true + end + + it { is_expected.to eq("/") } + end + + context "when the user is not blocked" do + before do + user.blocked = false + end + + it { is_expected.to eq("/authorizations/first_login") } + end + + context "when skip_first_login_authorization? is true" do + before do + allow(controller).to receive(:skip_first_login_authorization?).and_return(true) + end + + it { is_expected.to eq("/") } + end + + context "when skip_first_login_authorization? is false" do + before do + allow(controller).to receive(:skip_first_login_authorization?).and_return(false) + end + + it { is_expected.to eq("/authorizations/first_login") } + end + end + + context "and otherwise", with_authorization_workflows: [] do + before do + allow(user.organization).to receive(:available_authorizations).and_return([]) + end + + it { is_expected.to eq("/") } + end + end + + context "and it is not the first time to log in" do + let(:user) { build(:user, sign_in_count: 2) } + + it { is_expected.to eq("/") } + end + end + end + end + + context "when the user has the account blocked" do + let!(:user) { create(:user, organization: organization, email: email, blocked: true) } + + before do + post :create + end + + it "logs in" do + expect(controller).not_to be_user_signed_in + end + + it "redirects to root" do + expect(controller).to redirect_to(root_path) + end + + it "shows an error message instead of notice" do + expect(flash[:error]).to be_present + end + end + + context "when the unverified email address is already in use" do + before do + post :create + end + + it "doesn't create a new user" do + expect(User.count).to eq(1) + end + + it "logs in" do + expect(controller).to be_user_signed_in + end + end + + context "when the unverified email address is already in use but left unconfirmed" do + before do + user.update!( + confirmation_sent_at: Time.now.utc - 1.year + ) + end + + context "with the same email as from the identity provider" do + before do + post :create + end + + it "logs in" do + expect(controller).to be_user_signed_in + end + + it "confirms the user account" do + expect(controller.current_user).to be_confirmed + end + end + + context "with another email than the one from the identity provider" do + let!(:identity) { create(:identity, user: user, uid: uid) } + + before do + request.env["omniauth.auth"][:info][:email] = "omniauth@email.com" + end + + it "doesn't log in" do + post :create + + expect(controller).not_to be_user_signed_in + end + + it "redirects to root" do + post :create + + expect(controller).to redirect_to(root_path) + end + end + end + end + end +end From 1970e6162d88be88a4a58068ab32e9233868854d Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Thu, 20 Feb 2025 12:11:54 +0100 Subject: [PATCH 06/19] rebase gemfile.lock on develop # Conflicts: # Gemfile.lock --- Gemfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Gemfile b/Gemfile index c1e84b2ba8..f74ae5b11e 100644 --- a/Gemfile +++ b/Gemfile @@ -19,6 +19,7 @@ gem "decidim-templates", "~> #{DECIDIM_VERSION}.0" gem "decidim-budgets_booth", github: "OpenSourcePolitics/decidim-module-ptp" # External Decidim gems +gem "decidim-admin_multi_factor", git: "https://github.com/OpenSourcePolitics/decidim-module-admin_multi_factor.git", branch: "fix/decidim_version_and_missing_helper" gem "decidim-anonymous_proposals", DECIDIM_ANONYMOUS_PROPOSALS_VERSION gem "decidim-budget_category_voting", git: "https://github.com/alecslupu-pfa/decidim-budget_category_voting.git", branch: DECIDIM_BRANCH gem "decidim-cache_cleaner" @@ -65,6 +66,7 @@ gem "puma", ">= 5.5.1" gem "rack-attack", "~> 6.6" gem "sys-filesystem" gem "wicked_pdf", "2.6.3" +gem "concurrent-ruby", "1.3.4" group :development do gem "listen", "~> 3.1" From 12228101f5fa4e7d65130aa6be2c8e1fc60cc4c6 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Thu, 20 Feb 2025 12:32:02 +0100 Subject: [PATCH 07/19] feat: add translations --- config/locales/de.yml | 10 ++++++++++ config/locales/en.yml | 8 ++++++++ config/locales/fr.yml | 8 ++++++++ 3 files changed, 26 insertions(+) diff --git a/config/locales/de.yml b/config/locales/de.yml index d5cebbd42b..45e69db6b3 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -1,6 +1,16 @@ --- de: decidim: + admin_multi_factor: + verification_code_mailer: + verification_code: + title: Ihre Zwei-Faktor-Authentifizierung + subtitle_html: Um die Authentifizierung abzuschließen, kopieren Sie den unten stehenden 4-stelligen Code, kehren Sie zur Verifizierungsseite von %{organization} zurück und fügen Sie ihn dort ein! + copy: 'Kopieren Sie diesen Code:' + expires_in: Es läuft in %{time} ab. + ignore_html: |- + Falls Sie diese Nachricht nicht angefordert haben, ignorieren Sie bitte diese E-Mail.
+ Ihr Konto wird erst aktiviert, wenn es vollständig bestätigt wurde. notifications_digest_mailer: subject: Dies ist E-Mail-Zusammenfassung participatory_processes: diff --git a/config/locales/en.yml b/config/locales/en.yml index 4f057d141d..7356c55e52 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -60,6 +60,14 @@ en: success: Scope updated successfully titles: scopes: Scopes + admin_multi_factor: + verification_code_mailer: + verification_code: + copy: 'Copy this code:' + expires_in: It will expire in %{time}. + ignore_html: If you didn't request this communication, please ignore this email.
Your account won't be active until your account is fully confirmed. + subtitle_html: To finalize the authentication you just need to copy the 4 digit code below, go back to the %{organization} verification page and paste it! + title: Your 2Factor Authentication amendments: emendation: announcement: diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 1a31e6e320..b39a12a0b8 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -62,6 +62,14 @@ fr: success: Secteur mis à jour avec succès. titles: scopes: Secteurs + admin_multi_factor: + verification_code_mailer: + verification_code: + copy: 'Copiez ce code :' + expires_in: Il va expirer dans %{time}. + ignore_html: Si vous n'avez pas demandé à recevoir cette communication, veuillez ignorer cet email.
Votre compte ne sera pas actif tant que votre compte n'est pas confirmé. + subtitle_html: Pour finaliser l'authentification, il vous suffit de copier le code à 4 chiffres ci-dessous, retournez à la page de vérification %{organization} et collez-le. + title: Votre authentification à deux facteurs amendments: emendation: announcement: From 159fbee63b747fe68055a53bd37232f3889b5482 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Thu, 20 Feb 2025 13:39:44 +0100 Subject: [PATCH 08/19] fix: add keys to ignore_unused --- config/i18n-tasks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index dc6ebdae36..3ed2f0b2b6 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -170,5 +170,5 @@ ignore_unused: - decidim.system.titles.info.* - decidim.budgets.projects.orders.* - decidim.components.budgets.settings.* - + - decidim.admin_multi_factor.verification_code_mailer.verification_code.* From d18f133a03c180e147c31cf0f8786c1422f83fa6 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Thu, 20 Feb 2025 13:40:19 +0100 Subject: [PATCH 09/19] style: update with rubocop --- Gemfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index f74ae5b11e..c6d90edee9 100644 --- a/Gemfile +++ b/Gemfile @@ -52,6 +52,7 @@ gem "activejob-uniqueness", require: "active_job/uniqueness/sidekiq_patch" gem "activerecord-session_store" gem "aws-sdk-s3", require: false gem "bootsnap", "~> 1.4" +gem "concurrent-ruby", "1.3.4" gem "deepl-rb", require: "deepl" gem "deface" gem "dotenv-rails", "~> 2.7" @@ -66,7 +67,6 @@ gem "puma", ">= 5.5.1" gem "rack-attack", "~> 6.6" gem "sys-filesystem" gem "wicked_pdf", "2.6.3" -gem "concurrent-ruby", "1.3.4" group :development do gem "listen", "~> 3.1" From e33deacbe6ca3692318d46b8f1c49efe556da282 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Thu, 20 Feb 2025 14:14:28 +0100 Subject: [PATCH 10/19] feat: add migration file from 2FA --- ...reate_decidim_admin_multi_factor_settings.rb | 17 +++++++++++++++++ db/schema.rb | 14 +++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20250220125330_create_decidim_admin_multi_factor_settings.rb diff --git a/db/migrate/20250220125330_create_decidim_admin_multi_factor_settings.rb b/db/migrate/20250220125330_create_decidim_admin_multi_factor_settings.rb new file mode 100644 index 0000000000..76d30ebc64 --- /dev/null +++ b/db/migrate/20250220125330_create_decidim_admin_multi_factor_settings.rb @@ -0,0 +1,17 @@ +# This migration comes from decidim_admin_multi_factor (originally 20241126021907) + +# frozen_string_literal: true + +class CreateDecidimAdminMultiFactorSettings < ActiveRecord::Migration[6.0] + def change + create_table :decidim_admin_multi_factor_settings do |t| + t.boolean :enable_multifactor, default: false + t.boolean :email, default: false + t.boolean :sms, default: false + t.boolean :webauthn, default: false + t.references :decidim_organization, foreign_key: true, index: { name: :index_decidim_admin_multi_factor_settings_on_organization_id } + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index cbad410846..37236ddf2b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2024_11_18_114335) do +ActiveRecord::Schema.define(version: 2025_02_20_125330) do # These are extensions that must be enabled in order to support this database enable_extension "ltree" @@ -119,6 +119,17 @@ t.index ["visibility"], name: "index_decidim_action_logs_on_visibility" end + create_table "decidim_admin_multi_factor_settings", force: :cascade do |t| + t.boolean "enable_multifactor", default: false + t.boolean "email", default: false + t.boolean "sms", default: false + t.boolean "webauthn", default: false + t.bigint "decidim_organization_id" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["decidim_organization_id"], name: "index_decidim_admin_multi_factor_settings_on_organization_id" + end + create_table "decidim_amendments", force: :cascade do |t| t.bigint "decidim_user_id", null: false t.string "decidim_amendable_type" @@ -2157,6 +2168,7 @@ add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "decidim_admin_multi_factor_settings", "decidim_organizations" add_foreign_key "decidim_area_types", "decidim_organizations" add_foreign_key "decidim_areas", "decidim_area_types", column: "area_type_id" add_foreign_key "decidim_areas", "decidim_organizations" From 851e8aa2736a78345b7d30b8a4bd749178a35e34 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Thu, 20 Feb 2025 15:41:11 +0100 Subject: [PATCH 11/19] test: fix failing test --- spec/services/deepl_translator_spec.rb | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/spec/services/deepl_translator_spec.rb b/spec/services/deepl_translator_spec.rb index 457f3469c6..afc693cad1 100644 --- a/spec/services/deepl_translator_spec.rb +++ b/spec/services/deepl_translator_spec.rb @@ -15,9 +15,11 @@ before do stub_request(:get, "https://translator.example.org/v2/languages").with( headers: { - "Accept" => "*/*", - "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", - "User-Agent" => "Ruby" + 'Accept'=>'*/*', + 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'Authorization'=>'DeepL-Auth-Key dummy_key', + 'Content-Type'=>'application/json', + 'User-Agent'=>'deepl-ruby/3.0.2 (darwin23) ruby/3.0.6' } ).to_return(status: 200, body: JSON.dump([ { @@ -178,12 +180,13 @@ ]), headers: {}) stub_request(:post, "https://translator.example.org/v2/translate").with( - body: { "source_lang" => "en", "target_lang" => "es", "text" => "This is a comment" }, + body: { "text" => ["This is a comment"], "source_lang" => "en", "target_lang" => "es" }, headers: { "Accept" => "*/*", "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", - "Content-Type" => "application/x-www-form-urlencoded", - "User-Agent" => "Ruby" + 'Authorization'=>'DeepL-Auth-Key dummy_key', + "Content-Type" => "application/json", + "User-Agent" => "deepl-ruby/3.0.2 (darwin23) ruby/3.0.6" } ).to_return(status: 200, body: JSON.dump({ translations: [ From db7e0e7e62ac94b062c99e19c20c30e64972bf42 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Thu, 20 Feb 2025 15:54:07 +0100 Subject: [PATCH 12/19] style: update with rubocop --- spec/services/deepl_translator_spec.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/spec/services/deepl_translator_spec.rb b/spec/services/deepl_translator_spec.rb index afc693cad1..222b92fea5 100644 --- a/spec/services/deepl_translator_spec.rb +++ b/spec/services/deepl_translator_spec.rb @@ -15,11 +15,11 @@ before do stub_request(:get, "https://translator.example.org/v2/languages").with( headers: { - 'Accept'=>'*/*', - 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', - 'Authorization'=>'DeepL-Auth-Key dummy_key', - 'Content-Type'=>'application/json', - 'User-Agent'=>'deepl-ruby/3.0.2 (darwin23) ruby/3.0.6' + "Accept" => "*/*", + "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", + "Authorization" => "DeepL-Auth-Key dummy_key", + "Content-Type" => "application/json", + "User-Agent" => "deepl-ruby/3.0.2 (darwin23) ruby/3.0.6" } ).to_return(status: 200, body: JSON.dump([ { @@ -184,7 +184,7 @@ headers: { "Accept" => "*/*", "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", - 'Authorization'=>'DeepL-Auth-Key dummy_key', + "Authorization" => "DeepL-Auth-Key dummy_key", "Content-Type" => "application/json", "User-Agent" => "deepl-ruby/3.0.2 (darwin23) ruby/3.0.6" } From 56366cb9385c74ff4bb1ddf7c4e9953a69e9b4be Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 21 Feb 2025 11:07:45 +0100 Subject: [PATCH 13/19] test: add system test for admin double authentication --- .../admin_double_authentication_spec.rb | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 spec/system/admin_double_authentication_spec.rb diff --git a/spec/system/admin_double_authentication_spec.rb b/spec/system/admin_double_authentication_spec.rb new file mode 100644 index 0000000000..cd3c52eae7 --- /dev/null +++ b/spec/system/admin_double_authentication_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Admin double authentication", type: :system do + include Decidim::SanitizeHelper + + let(:organization) { create :organization, default_locale: :en, available_locales: [:en, :es, :ca, :fr] } + let(:admin) { create :user, :admin, :confirmed, organization: organization } + let!(:setting) { Decidim::AdminMultiFactor::Setting.create!(enable_multifactor: true, email: true, sms: true, organization: organization) } + + before do + switch_to_host(organization.host) + end + + describe "Access back office" do + before do + login_as admin, scope: :user + allow_any_instance_of(Decidim::AdminMultiFactor::BaseVerification).to receive(:generate_code).and_return("1234") + end + + it "can access back office with email" do + visit decidim.root_path + click_link admin.name.to_s + li = page.all("ul.is-dropdown-submenu li") + li[4].click + expect(page).to have_content("Elevate access rights") + links = page.all("a.button.button--social") + links[0].click # first link is Email + expect(page).to have_content("Please enter the code:") + fill_in "digit1", with: 1 + fill_in "digit2", with: 2 + fill_in "digit3", with: 3 + fill_in "digit4", with: 4 + click_link_or_button "Submit" + expect(page).to have_content("Welcome to the Admin Panel.") + end + + it "can access back office with sms" do + visit decidim.root_path + click_link admin.name.to_s + li = page.all("ul.is-dropdown-submenu li") + li[4].click + expect(page).to have_content("Elevate access rights") + links = page.all("a.button.button--social") + links[1].click # second link is Sms + fill_in "sms_code[phone_number]", with: "0612345678" + click_link_or_button "Submit" + expect(page).to have_content("Please enter the code:") + fill_in "digit1", with: 1 + fill_in "digit2", with: 2 + fill_in "digit3", with: 3 + fill_in "digit4", with: 4 + click_link_or_button "Submit" + expect(page).to have_content("Welcome to the Admin Panel.") + end + end +end From 63c628199447404c2faf9f2e62bf8d74d09308c2 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 21 Feb 2025 11:48:26 +0100 Subject: [PATCH 14/19] test: try fixing test --- spec/services/deepl_translator_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/services/deepl_translator_spec.rb b/spec/services/deepl_translator_spec.rb index 222b92fea5..37273e4e3f 100644 --- a/spec/services/deepl_translator_spec.rb +++ b/spec/services/deepl_translator_spec.rb @@ -19,7 +19,7 @@ "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", "Authorization" => "DeepL-Auth-Key dummy_key", "Content-Type" => "application/json", - "User-Agent" => "deepl-ruby/3.0.2 (darwin23) ruby/3.0.6" + "User-Agent" => "deepl-ruby/3.0.2 (linux) ruby/3.0.6" } ).to_return(status: 200, body: JSON.dump([ { From 8c1ebbaba1a52b3f00e2d74b15f03061e25e1f63 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 21 Feb 2025 12:01:08 +0100 Subject: [PATCH 15/19] test: fixing again failing test --- spec/services/deepl_translator_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/services/deepl_translator_spec.rb b/spec/services/deepl_translator_spec.rb index 37273e4e3f..787a31bf9f 100644 --- a/spec/services/deepl_translator_spec.rb +++ b/spec/services/deepl_translator_spec.rb @@ -186,7 +186,7 @@ "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", "Authorization" => "DeepL-Auth-Key dummy_key", "Content-Type" => "application/json", - "User-Agent" => "deepl-ruby/3.0.2 (darwin23) ruby/3.0.6" + "User-Agent" => "deepl-ruby/3.0.2 (linux) ruby/3.0.6" } ).to_return(status: 200, body: JSON.dump({ translations: [ From 7ae81a9756065237dae0f849cedd28a202127e0b Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 21 Feb 2025 16:07:32 +0100 Subject: [PATCH 16/19] fix: update Gemfile.lock --- Gemfile.lock | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index d426c39539..5386f1f343 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,12 @@ +GIT + remote: https://github.com/OpenSourcePolitics/decidim-module-admin_multi_factor.git + revision: 8fd7a2736962259cb1bcad1fc1dfbd973bcb9055 + branch: fix/decidim_version_and_missing_helper + specs: + decidim-admin_multi_factor (0.27.4) + countries (~> 5.1, >= 5.1.2) + decidim-core (= 0.27.4) + GIT remote: https://github.com/OpenSourcePolitics/decidim-module-anonymous_proposals revision: ea7c828c82fabb1c35e161095082f15ba63b6eaf @@ -365,7 +374,7 @@ GEM coffee-script-source (1.12.2) colorize (0.8.1) commonmarker (0.23.10) - concurrent-ruby (1.2.3) + concurrent-ruby (1.3.4) connection_pool (2.4.1) countries (5.7.2) unaccent (~> 0.3) @@ -1178,8 +1187,10 @@ DEPENDENCIES brakeman (~> 5.1) byebug (~> 11.0) climate_control (~> 1.2) + concurrent-ruby (= 1.3.4) dalli decidim (~> 0.27.0) + decidim-admin_multi_factor! decidim-anonymous_proposals! decidim-budget_category_voting! decidim-budgets_booth! @@ -1238,4 +1249,4 @@ RUBY VERSION ruby 3.0.6p216 BUNDLED WITH - 2.5.22 + 2.5.10 From a6ab0643fd8127b709431fdcfc068032eb945d78 Mon Sep 17 00:00:00 2001 From: stephanie rousset Date: Fri, 21 Feb 2025 16:30:25 +0100 Subject: [PATCH 17/19] test: fix failing test --- spec/services/deepl_translator_spec.rb | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/spec/services/deepl_translator_spec.rb b/spec/services/deepl_translator_spec.rb index 787a31bf9f..b0ecc5d658 100644 --- a/spec/services/deepl_translator_spec.rb +++ b/spec/services/deepl_translator_spec.rb @@ -18,8 +18,7 @@ "Accept" => "*/*", "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", "Authorization" => "DeepL-Auth-Key dummy_key", - "Content-Type" => "application/json", - "User-Agent" => "deepl-ruby/3.0.2 (linux) ruby/3.0.6" + "User-Agent" => "Ruby" } ).to_return(status: 200, body: JSON.dump([ { @@ -180,13 +179,13 @@ ]), headers: {}) stub_request(:post, "https://translator.example.org/v2/translate").with( - body: { "text" => ["This is a comment"], "source_lang" => "en", "target_lang" => "es" }, + body: { "source_lang" => "en", "target_lang" => "es", "text" => "This is a comment" }, headers: { "Accept" => "*/*", "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", "Authorization" => "DeepL-Auth-Key dummy_key", - "Content-Type" => "application/json", - "User-Agent" => "deepl-ruby/3.0.2 (linux) ruby/3.0.6" + "Content-Type" => "application/x-www-form-urlencoded", + "User-Agent" => "Ruby" } ).to_return(status: 200, body: JSON.dump({ translations: [ From 1e922505541c16bafd13b42100b8d8bb13917604 Mon Sep 17 00:00:00 2001 From: Guillaume MORET <90462045+AyakorK@users.noreply.github.com> Date: Wed, 26 Feb 2025 08:56:25 +0100 Subject: [PATCH 18/19] feat: Add CCO's SSO to decidim-app (#671) * feat: Add CultuurConnect SSO to dcd-app * fix: Add missing locales * fix(sso): OmniAuth::Strategies::CultuurConnect now inherits from OmniAuth::Strategies::OAuth2 (less code) * fix(test): fix callback_url mock in Omniauth CCO strategy test --------- Co-authored-by: moustachu --- Gemfile | 2 + Gemfile.lock | 1 + app/packs/images/cultuur-connect-logo.svg | 24 ++++++ app/packs/images/cultuur-connect-logo@2x.png | Bin 0 -> 18813 bytes config/i18n-tasks.yml | 1 + .../initializers/omniauth_cultuur_connect.rb | 17 +++++ config/locales/en.yml | 4 + config/locales/fr.yml | 4 + config/secrets.yml | 6 ++ lib/omniauth/strategies/cultuur_connect.rb | 65 ++++++++++++++++ .../strategies/cultuur_connect_spec.rb | 72 ++++++++++++++++++ 11 files changed, 196 insertions(+) create mode 100644 app/packs/images/cultuur-connect-logo.svg create mode 100644 app/packs/images/cultuur-connect-logo@2x.png create mode 100644 config/initializers/omniauth_cultuur_connect.rb create mode 100644 lib/omniauth/strategies/cultuur_connect.rb create mode 100644 spec/lib/omniauth/strategies/cultuur_connect_spec.rb diff --git a/Gemfile b/Gemfile index c6d90edee9..4ffc6a84b2 100644 --- a/Gemfile +++ b/Gemfile @@ -43,7 +43,9 @@ gem "decidim-term_customizer", git: "https://github.com/OpenSourcePolitics/decid gem "decidim-guest_meeting_registration", git: "https://github.com/alecslupu-pfa/guest-meeting-registration.git", branch: DECIDIM_BRANCH # Omniauth gems + gem "omniauth-france_connect", git: "https://github.com/OpenSourcePolitics/omniauth-france_connect", branch: "feat/omniauth_openid_connect--v0.7.1" +gem "omniauth-oauth2" gem "omniauth_openid_connect" gem "omniauth-publik", git: "https://github.com/OpenSourcePolitics/omniauth-publik" diff --git a/Gemfile.lock b/Gemfile.lock index 5386f1f343..bf25855409 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1229,6 +1229,7 @@ DEPENDENCIES multipart-post nokogiri (= 1.13.4) omniauth-france_connect! + omniauth-oauth2 omniauth-publik! omniauth-rails_csrf_protection (~> 1.0) omniauth_openid_connect diff --git a/app/packs/images/cultuur-connect-logo.svg b/app/packs/images/cultuur-connect-logo.svg new file mode 100644 index 0000000000..6eb6a5447d --- /dev/null +++ b/app/packs/images/cultuur-connect-logo.svg @@ -0,0 +1,24 @@ + + + + logo + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/app/packs/images/cultuur-connect-logo@2x.png b/app/packs/images/cultuur-connect-logo@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..f92468fb0362c86ef5e5030f8c98749ce8311b41 GIT binary patch literal 18813 zcmV)qK$^daP)t2^|BW1PsOnY}vBhWy_Xr zbzRwc-<&L2y4$_my_-E+VZukHw)sFLxXYa|+hxZwyN0#ZoneFhnD)V~LRJMh;e z{tys8Ypmyt7B%5-6OE&Kr1v9@I?@1vls+hF(BF(|0$)cZXz)z@P1HGFexeC@;l+78 zUa$(BGyVum5}Y=G9O}@gH5ySJI#maWV-1a=4NB5T?JZV>kXpPAYr_}ZO=}@&Gn%P0 zn#FXG0L};>W0)aF0`YJLfI~gxjWwLnr;si_ARm3es%U49K#=loDndv(ai|IvZ~@G#e=pxT_N0%XvI&Mt)%@xG?hTU1D|LW=0f82}`9R1tziFnf#QTd4q# zi0BBe0JkUdQOC*0^az!REks}fQS53NDqrO4Fpj`{{~Uu_Nc=M0YwN#s8|f+n`jh<0%zmtItN(} z2N^&~u8wp@0A#Qo_i3@_qKDadT9VFf9gMaqLa>QCB!N;$0vF<;x)@mx#aUr!6T@^# z4suwHoA&^84iB-BVf%=5Y1*K*Lp{tEpbx+F>*yHh1g^kCbT)qHTgax8WC#Ly3B(M= z-bwnD+p$dmo=3CN%UFU6mr z0eX;)rY-4r!<}uBA_N0`c|Fb2b-D>wffwKvz}qy)hj^*;X5bI@O5*IP=^+8nSiJm2 zDQO(nJ3$QVddL9;!39b9@z?!v52>W26{)KPFGc{0$cLba`~`Fk8zO5S_u%^c?Yylb zq`OLVJyZW&c$(KaZ1RP@{965I_JLjAPHk`G@$M zp7OgP#QTC1h~*zic(}Vzt;7mvZKLZg!4o~5Mm!1kw zC01|>q=7Gk0OEgWQrJ4FRdKzQsO=fzt$zhcyn(V|u$}IQdpxmWzb006u0czKeCd|u z+rIXCzIR(k2uhkn57wqJ_PfHOqTlYlXzcDpOOc%~CkX*Wa59MC=O6;eC(+135eb{ljNYR+V7c_;#27H&NNGAnDkuBd)jcm+IFxT(Bf> zczZxEOt3;^V=ct^t@`|#e@wg#c{y{65U`y1AdtY__=9Yog2P<{aq~hD!9`F8D(mI` zi1Y5xdWfOrK?n_LAUU%k#6~y&5U~;3N{6Ad~es9dshXg#Z4R|0b}feDP`>2 zE9XrgRNoLeK?q`XA!gWqh0b0MGH`f+j^YbXBUW-fh6G=U@d&6WfdJQ`UuaoKeD`lL z&AD}OKoMk6y@Z(efQ0LBXkOWP_=_%2V@ELIVFXCwU-??WS?yPi-=1(olFXRZgn*ad zP3!1d3=e*a>(V5#ik)36BPzZEL~t#J1dt5WVG9AyyFm;Of*5`Sn}-Hld6P?wVZzAg zY)b}4b?Tu#`mdL6s{8R$HrXiNQW#?!eR=GV_RFR>T1qcDQPvUy^aO{22o{0}oK+aQ z;JvKu7LdW!un8!Te0y@QxbQ4R>!Tos+h7T>+Hn9_`A?xY_%EsT>}XlJ2hu`UeM@je zA_WUc0mFx52V?k(8o7@M=7R{7C37MVeylv4V>Y7gN#eV{ z4~b!8tfYy@LC3?!ei2uJm`DX&Z_%%Rdvo0%zt9ts7U1LdPoZn3UzteV$>hl@Lf9u2 zJP0C~lnf{_^THgMmH!wLfg*yKI7x~WItJeSQ@Cus3nd05mD*DbtAr8}N?!*L{(8fw z8%})1JsOs;_UD!vyH1}!<6Z~b7gcmt5JFfg=%$~eGdDUX6}X8?z6COXi0Oj9KpiPX zfOoGX-gGTd*AhAPdL|JYBcuc8UxD3it6OG0r~9|Ihlbf|JY%he`L3GQZ%*B1D_;_c z9oD!f(K3r!u^f2{^idzejP!esk%8~4czBkPR`EMU1{Uv&>}1S4owVw=hz7Hm}{ zUwqN|DIo*V(6XY*#SQ0Il$}%Kwx<-~d`M?^=;q`*YrO>L>Ct*yY^Q`>!9^HqE`ai| z$aZEc8HB>H;(uYWcBYloDou(I;Gx~nE!>J(iThC_?tTIX8?zF&AxW7IBv7l)?_0jI z?pyz2)`zr=+6#QX@oksX?nt0Q+gUPFLV#3|A6iTIVO;izV`Kmu_V7ca$iVhP<#Yxy zWYdN(Bv$p0#H0n!#6chffr`eio;h>UYbSMWwu}*{!miFvXjW5mWE>$taafB5cJJfE zaFrtvxvDTP{Q_x~4`Xe$BaFw`t!4bJKQd7 zT2h^uqngkDP-GM#Kr%QQdV&ui70mA632E1bXZA=1uryRhW+1@aAn;Ld5S3yGVbPl% z%X8-Ov4hy8iUWpkIZtaZ^0?rMCeFe6$h?K0&y15wN_55$0wjY=AsM_3GH~Ftorl$J zcat{q8At`r8Z=6|YpRd|2#D39+OXRo*)4`tZozWci`AxSa10wVt#sRkK2}xW+8M7o zL9VC7M@QNd0we+!T1UTvKf?z>2C?C%gO{f<$r3d4s zQXaF}7#60>QIJ2baNT*uMI#H{Vl!OR1CCj~Y8ojqBCQEQC=VeSEW#4{|CHG0{Y4d= z9DaaU{@ndx>MElV5KGJ1s3kBQyT-g98oW}a(UF%oDu3O%zT)9NSF>5OIOA}k<@}Mh zguolAkca3cNCoFO0z=7lVdbG3A~=HXpccxQ8?z99gb~?KjN6fBgdiH}^^Wz{&+`sqOD|d#n4So*l561jWDy$IZ)ZJ75b1gp`X=T#oabgn zvkxH_$}K|R8>x!7(_0uO$e$>NGgCI|HLQC-IbELx)h>q+U`1#57%>Tt)){aV`)Y^O z&vSRX7xTL-k2amVMx(_+YgH3=mcBK!k?)@{58AQj_>t2(T8X5N(8b;M|hiW+PL zn0Gcv;a_M*X6TL%7%^kagGYwy`fy{yNrbbe3JA|3I$@-%OHdC(u-CFDg;uE z0MmxBw@xb?dF8~Nj!OuG<_`hOmwyfs=tt7QWko%bLH;4qi&h0H1O^iUzU7FQwCZhSBXcwx{NxVCld|bqLIO8@jWrTo^_V4EIRB5FzO#w{CBRh~k?2?U}EK@1=6DPKLmqSj zBQ_i^(v{(n6VR$M(+z=8v_mBjLJ1Gh?_@UBQ9?k2UA&7PgITf#M|U>6RdfNi;kw9X zCY4+vkeLW@0|`O&1n<^47&=Jv3hXE$P(A$!q+ruWwuKw!9=p%+g@v>#i9#S75HKMG z(KWnd_X-bVGP>y)A%L}JVtV9(2$>UtXc2?b9ty^!9Sb=^ z2(*rF0x8I!V)V{6cbqv$_eoW&5J(9E!7VYX34erxbe}lKOc*CpIw4@FFcFf$Pvg{U zKbjTpvo}aze|YvgrE(|);v>M}WRzm)5PC}pe}wDly58T3oTU;18YFzWJb<$=k;cTz z!z%89qmzlTlBhU^Kz1S!+KztVGi%|%yY~~mfyyi-HB2Iv5HPE70h)c3VNJ;d7Jd!u z6s}071eI4Ikhus1w^?pG2%%K>Q^LDNOCyA^D>R*cW4Yy)6N-Ho@3Nd)B~%Dx9ReZi zgdqCP7~O!qO%n1ZD~%ASlWvA|P%ZstV9!P6`A9mds=!P_AlPWDwU81D^dSA%R;GQ4 zr4T~cJeemhrkFNds~}e9fGLA%g(^ZJkcJ3^bqMy_1xF`WVH&c=UaqJ;uIy&-Nw0U( zZv-nPmfJiR8~RgafMgDjipdcK%*>W!bsy5Y2U3FV*}kMD1WYp?gfYtnu^N|9T;6o- z;OnTjsDzrOGARU7j(~+JN24}DO1KCv*Jc>SnS~`isUM!^z;vTo1&M~Up?5$zl9Y`_ zqE+Wmz7X&3-W+J^Xlbo)3AQ(Pv*7Lk3+?J+ys4AXj&6-LbupJ;XKc8S2Zwt(ugDM4 zP%mf0d|a!_=ib^9?~pRz&?1+oQr#@4o*aXfj#jF|y9qmg`UTRSVI{ptlhlh0>0qv| z(}z|ovz)HrWYVf$vYcEcw15Ej^QP9i){l1jo8R5x_P@T>o&RxjXk;*C(Y%~7Kc>Q4 z@x*kaHu%Hj`NWwxho#J7>{xu{aJ}ickpcarQB*vsd{l+z9+tEOYN2OYpG4m{o5-KI z3q{RMhq4;_zzR@F5rV!JJ1%iL)k;Oy)3}L^Sq38{`CB|%Z$oyZ3o~sz+t6bc@LCd37k4CLye0uB3dD9K|1gKcFvZ!T` zRuy8wDz0l)$Ul)0pUF9&abmURzF}&&>&THqDhqwJ!l`aj&s%of75lx=*JJW76x(z; zR;jqR&uF!DcC0kLaS7ib$E1VeYe^e^Z*Tk5*(L&9Z{r_st=sa$Px1qAYz@^IW@psF zh`omQbFoKrz0!H*9_;Rg9qPnaB$p_qbF1|twc!T$F_UKt4!0;Gc%Asw6&uL4`~S_!eSYcaDh%vLs)m?#+77JOpu z_J->|$RFO)9W>bYobAGc@i^mbakaPL&4$Zrx>Ai>{sXJV4)^-cziV#$S*MPxn(g(D z^QO8DnN}aimgsz)6qxRIO{5#+^*CL1KyWMuST+YzexwhlB zPrEkVzJh9@&oNT$t;X!~-Bsnq4{e?|x-(s{@+|n`pnDG9cI)NS#-8T&)<}8c)1_}R z+rgnw3_>42>!C-iNntrJ^U{v#YazoZo~F-nMr-M(^lU78Szb+ zWsq6SwlyDLMS42;RxCoYX$9=Lk?r~R^I>}CD- z|D83i_`ug${=xFSb!QkVyz?#MTaJ;|YDfp~U<&d`vjXi&2=z2iIvJp4tTe9j)2#j3 zAI<~(51%fpJLm=XC?hfeqc5r|FF8D0$UxlC@s-zp#_~5*cI|z33Fh0RPlsmILxW9C zWFA2C2$(Y;XjY~zLO@~Hm=%<8xO*H?;h70%&*qFmad`GW*RK1{2beI5JcQi<(|!`1 zKjZv#nmveYW}?3tn-}txo^|xftxw!8Y%3BiC*$)uV7?Cpf~lJ|%Y*<{sTJ%Z8(>y? zgIcp-0BT29eb?kCn|3_6A!MXt#!3Tro<4v2Pt$n@!Ju2VgJz7n;MSF!p8h`9TXK^W znEV-8d`iv{4vma%hGjwkt9;X_fTY5(HzO(6Xi*RfGN^vs-;D)FMqY0^HJ5f`X#Vsk zqcUb`z|3)%{`Td%mv80z9#idjmUbQEY3c4sg?GjxI5aX8%PkQCBm*%IbcuA(K?Y1S zFshSYQRWob-Mzs-VNrL--tM5mlp+PO$@RS8RnyAsfEWmIwh;6~}=T#>j0MR%T+jppN|r1pHfqQ=aPFv!gRqzCU!|6?6u$ zFSsIf^~}XlaXAuj<3XqV*TrwtKgC0Pb7%PAa%iH3(ed!86g69Di4b(|z;MBLwA#73 z--{dyY!A(Tx}|AjThPo-^?RYKW<1<4OHN+C)Kq!R_y1M@5fPrs95|R(gkI)J?8fo~!oh6|RKPTFZTgJq&RdJy1xD?jx4=B=w+LQ#w@Qec>{K%YP3E`=A- z-iyZWzH61|jwinOY+cUa;WGP1B!>VPHEP%pmk{6%<0P<&p+++C=3os@(OHRS&$68V z{r>v8r8`5T4ZFqI=Oas%8E1cNLf6cE;-V#<#=4!$#11Cef4E0E(nq}4GqF%{fL(N4 zLVyOxP#c+43w_68iE~9#MPy6nWsRS1{pE7aaDft3xqcU87iOcKL9+J`?)82&>se1} zTW4M3jslWxM5^*TL*`+L_2n1yq7{gJ zyl60flbD16|0>5IahZhs z*RKee;;Z2TalM@%w%Fg)9n=k1jbfhn)zH<`?(7Ggx{O3XV}D%U9ISq(?(_ALQQ7pH zxg}5V?E?qT*d{RP652q0N(zI8GW(%~!wvZZ{et5$TySVAWq;Gt{_58sud7?O(`=|f z0e7D3+HAK%O!g+_bM4tK3f+7Do9;0!W7l`(dut5IHQ9OwJ-??W=_cUi7ap=c=8Qhj z7`+F7%#}7rHC!Ot*}AIbM@vn}0CxyCZ$bBE)9NB`mij#q zfsXmpwrLBOFUO*C=34HmCZ^LUS1$V5SY^2=;5FfumLWVE-y>;7r0IfyEz9|ACMdrgOz=f~IU zwSQ>)qOGiGikP_1t|N|wub~beE4ITW{822lS&Bp%w5lQaKkPt9b$bF~oSzxR0H3#$FYVcUXk&q(8E zyEkDvoMAs9zW&R1wKu=8$B9=u;!B$}aP9`5O610*Vr(e~o?+Z)c9G;+LouEB}v z{rbPtD8BxA+N<02@Yf%Um-cP3x1RtaTc{BqXZ9(HOqZOgl3> zFTMnqzvF}U+vJABW3tD?|JNiov-|FF>wV-4?4^O$%h!FFUwp=-ARb{i3~dML*!T5c z(cH1?`Hk+b=&!%M*PHV}k`SOj@WNf%m*m0--tiqSx5x}CZRL*78yh>^rc=1^bntBF zod+?f?l5!FRY z2n}=qs8#L)Z10z_gg5)9ha<2w{rRo;z1TU~Y-T}BBe8o-GpmS%5V&o*s~O5gX9xjb z|CaFBze9}uJDynYF6;4Q(L46H_w~2ZLD&gaSxJ{{nrK4M>Cj{gjmn?H`h)K72LHQ_ zp|Mez^ae1tCOCikJH0>ESq=gZ*72G@9=TVSvD0+#*Z*BH_HXdASGV%ffsl?bzof6f zr7rWza~E;flk=GQm&H;!u@glR0+vHcCj_4rG;bh09KE!6y^weu z^#Kt@2v8^kq}(Diq%=L$tiRu+ zk1=m9YY1F5^<(pR6{8S{KmayE5h2ol4j=?5xa1OoXEO8$&P)YMM?!LeoA++sjg5;< zE#)S{$R3HOuf=aAX1y>WMTi@8fiZlQJcoziU?6Z z6#}sl(5O9jg5+cS2my{3Ct|u$u1MmUmV65{w1oRt=f%V_PiJptXxGxJ*_FGi9sg>k zONSY`9e1R)pAZ6)hYLWF<>e+Jgn~`_$2)^D^#`5vr*BM`2Q8D^@$q~X5c^kTav!X= z-OZTe&rBa7K%qF@YQrq2%TYqu*4DT?7Dmw3sNq5^fb7O`a{S9_w@K2|gY<5_a`Fc0 zrl2M!8ESZ;{ul@;R_-;FU z^brESS~XIu^3+0-le=hAYWo~4?DefKb6aK9>&f#d2|*_QoM*j+z=$o837J`0eT0A+ z2DyZgqr(LO^gp(^i_8EgMtiz0pHy!euOe(CfaT+{B{JJ3reOx2nrZ4VA@KE-j|bJ% zfk#5{GN?<9Q_&J?WJY#=#DO$*eF} zXBb+$Hh0D{NCL)+5M(}RqO2^p>A-N|shw9$Ny)INhY&)NhYR8+<*0ElWA_Q2to4Q zZog{0@DZ?DaPzwTmzSs_lFghX1nK4 zh&iO_pIlvAN5G@G?#MQaMZBDMr+P(fm=IutAeRt4~>S$nGKiKbfvnfjt~O5WBRpBbstk!pFgl8=E^&)Cf%5_ z#^hi{9al`*s%82f7g)2Px#%qa{K;Po#x3k21TI%XAXjb@LR)uR%&!xMW{MCTe#*}* zoBkur+yBksYMd#aQHRFa*MnEheAAf<;+LwyASoYTF1d>ia#TXt(dmy(2pXyF+TuTx zNu>}QnT+-po{#Cecakb7)tMP<)<`=sc+K>Gq`K<<^=Z^dL;43uxz14?!me&T=DK{3 z#+8I1Ez6^pYB~2AJAEEg(uh1Bsh=($*q%%kb z?|NL?0s1x57o-FXi^dDMD^as|PY}XR;_kaQ@7f%iZo7n1Q%U zEI%K-t}$IGJT;egJfwmny04tRcJSI_T(g_}l7|dAsy`6$ZtlRh$ZYE?4sDG(1`UCI zs|9Zw_u%>;Z1Q)Uh5K|llwQ*voS>#KGN& zYes%tv)vdkZgw&L-r*CcfAZ$BtndAIbi3x>#!EVj%~_-p0?T0uVS5)dow32-Q4xZq zht{cLnlGLxT(P~q?=PWhgc9k^gmR`wCJoJyN(ki6(RB;}vNK2?)7#4RK~!d$wn{}P z1O^}=W*IQuXEG+R*WZ=gdp)TQ$;z0fP6w=K4)5*0j2tNa|rU0NsehgUKC%NNkzoMFn(jI%I&woRSb6zXPg*WDyWL1o0>DFxRWet0OH- z3h`J|RvOb3A*AJfQ;q76fQDHHxjKYgl@Pf3G6n&-%f;II1Fx^!4QV%6)rqWgnUyFnN1gQEI0{svW zLk1-w^aCWBOOK|RNdji!$yBHEDg>k>AbH4O>JC79!_%?AJk3NBU|g=PM>=+1wJ9qQ z5JLueN=25J6`&;Ai9E9*1J6-6)QJMA@(mgS8W!xz8!|{#M+_PwNmT50nPM4xPBMu) zRaS+7Oa$ag2wWn!$n0f`%JP~?2xRUjAw^A7)hGlkAs}=J@`eniA4p4W$@oNNUO2Tk zJh=7~&U4g2!w?V^n5GEGon^>z31OsBw;jp~`2<2h<(vRD>YuF;m4jhk%%6P=p`} z-;o8_r^n<7@}MFF6Kqs)`XC_m;vLeO!zGSBq%|sa1yy>@4UlvmQiPDYTcK)75CM(G zNhAa)*}1u1Ex{&cPPU;w^L-hDL5A(X)S(baZv@5#D?Ex0l~Pou z6;kTK#xlZL1F_lsi;D? z8xn%?Oas@sk`U7PW_s@b{6HSk&*w}(b}#RogBM@JMjJgXw&vQ#hw<`sBo%*n40P(~ z=p=2y^3|HgUSt}3Byi>QPtq8IaMOATVW+f)>S6=k+&qfBg;?%s(toBY2_X$13-|pi zj{_0h8VUtZ2sbbMHd|u=i`|*5>wGfl{3dO|idEPr?fwx|e&5#lRq~Yi78_zES=gKw z-4{a!IfocB%$7tmCrp}$KT=%U!xi>H!&S65pKk^hGA$dim13-4$Fr$(kb454A92*D5-=K^6U^lhE=?}!?v zm$|pN+{2@s-MT#^+;d0G8TF^*r%md-e2i!PJbmY{4&nn|-oeJwC1lVcTNvVXl6A#HV ze3a6rR2IT~Z1ym1i&2$&Q0WnjaFHEWzsY|q41gx}FLy#|MN%v7JbBfgL#twYPsgQm;9$>eAa69rK3yDq5-ap|Czez_lC+L9{T?ApXtS zbY1%1*&|F}f1nO3Y#7&g1&Cx_0Q zwCmDI#XBx^wLWxYaP9PRx8a8%kve+wU`q0+C3UL?^rtt@iL*0=5NvRwtr=71F;yY2 zQDZr=QAXh^OdbO5UHWwxDhx0QzF|tYgZA_;Hgw3;As0_Op!TzKr;O^jbcp};lY$#g zAM09cHgvSzTh9N>`{1gHw(}*BVLu_1%ajn(XnHpRP?F6uz06}cUS*t*OSWv4-yDH+ z7r|o{*RPFCadwSsSY((}zrEhEc}I^uVC-`zO|A7`Sw$<)3~m398ML~{B zWJu8c(>ZaixyrOD!)I6i=I|Ml_g+?8*m!}q_rAkJb+yHA+nRTnTOX5(bq{@nun&-r zr2v!TLk5?p(ls>SwI>owxL}ALx5}gIgzE!pTaj0^O`!pjZE*@Z{}eY#nVcz zm@#|e>ImOeYHk?@PT1SjwlF!1y^`BWtl~j@ega8TQ!=y^<%^Z-nG@`e} zj60t^ibpRg=X{87;mR=?*O?4yFxS~pkxjjFVtOtr%lFpgpHOqaxR*|yIw5dHrT*C& z!EHB8*H-&HnyH@zC>RgIO3l+F`zZ7G078H==HF5Z2O;FLvsHoNMH9LWwb}trYSxgNS+b$dL+k8IlzUNSV z&FCTS=#L7cuo<@1vhAiDNC*Xz2tn-e=wEHOEm@WR(8>bghcD{T8BLE0Q~^mN(9+dC z6PAesp67GNQUi094hT!SBBu+0PmPf!Gs>=;J!?|Kg;R#LT$11U{8vKtM~&=Rh=sAm z3HkSbQR7rI27tw?XeS0+^-?PHuar_urWR6MP+Kr0&lNS@#Yk5Yf&({|8yJ24U{)aQ zmO9()IxFAwZ8XK2Q*OQ!srrGnJ;Ok@aWmZ!N+j z)qlPc>^Sd{&87xU2s}oH!ytA@OQYb)30?ut%T&T zE>uT%L3%h2;fBiXzRVF913(PN70^;lKN@c?5Rp0N&%jz3jU{fJ6|?LNiS*`;^%`l4 zL%4Yv(uK@vroMk)&6(xy&83ACvwG`1R(Oi(y@BK~9+BUL^za&_hNjS3`ahg6g!E8m znrUA|6d}OTS3BZX#mg8!UMK37W9JmVuJldu^VwZdtKlHW9+AE+&d`=&AGO|=-+f4j zHAA1?44HV&KLoAr>#}JNAR&~%oZx&&507Ho)g2gGyus_}CJ=<-+K4DZ5G4c6H%)7> z6VW9{9bu=+bW3~AgyJaC6?z0k2oBvSuUqRnYSfr0%pIz3(2B*1OCnFLt@`m^yFn1g z;8T4E2x1L}7N0|UxSiM2kq8falQJX(1peJtO(u!CA_Vf~kM|u?5#e!%Q>FKTmGW{o4L_Y26Z)T5ZK5(geRlRr&NrWS!Ozy#k>bfYaYk}ek90eNC<2+2?rl< zC0$!^Bk^EvdtnwB9yYRiRMgsdogbStzN&x$2>fggUnkQM%7DVt@rTVJL)d62Np{1sTfSRNb0`Gi7TB9?$pkI7RA83=ndHFkQS4FMP%j7-|X4~IBarGFlcU<0J zwjt$pyGP5blF|EEy3k=t2y6na0c)&Jy2j4elP)3~3K)R}D<_U}*Z1!W*ZGoNN68y_^+Y7S*a^LZnqe5cm zpuPjsk7xA#P#1|KP*3xDsBNGGmUw9ylQV33;gYsoBR(NuJ$dqkAZVrY&5Qxr$%YOu zo?d$LXn4r!Jvdc+7JP9~?@x8Mj{x6HL%B{rx1Tdha%jdIh1uyHMgncHZ44{>VoC_` zUHb+SciSo4C@t?$!-ig5{&EB#)_X0^oC0U{|E;d=A)t5BFYw73AbHsS5|a=_BDjBixHoaa=fG;>%FkjSd=DK{Qq$)=h4YbbuUVy(i+$)=yMQ9m z%Y#_@iWZ=E@R_IRq?@15#z-w%ZT z?fuv4$_fJdIt&#!&Dx(f{-eA^^%fErMKQL35zYVgW=QnR+xn!E zM2pC(d{c+d9`T)Ne2wT79_*v@?pvumDOtS3Sgm&qK2kRgNX~N01fj3*dX$&Gz9=gf zOxr(P5b5I*LO2nVFl^|02h)$uR@#^|X?}aysM-+)?y&R~()qPH)A1xaD%SMl1NBhU zP(cwwVyL?4x&9d~5dw^qJ^~|u-an)L>kv_U^7={p^`>tQ%~h_gI-BK-CA)aYzftn& zhO+c+?clczB;e)&T7>hMsMfF{Q9(Hoz!R4cApI|A;|3ZZS|S7iEJTZklLeT!t6@VV zh9w1)i&mc*=!EjNu)VeI2a)mW_kIN6+~XdQ!B_T&^ty>o|7{Cc&slkr`}T75C3G9OmuO)}1y z#i1P2EW|q#>LK1`;S%MM$jp`r0h-9o$ef&o5yvi7ZO=ruLHz~-2UZ<4;_XxYJN%sI zuGO8%lK?U}8`g;Tm_)NoU>deO3r8LJFJEpA~Cg02qGiI+WR95u=|_0Mv;=; zBCg9|ZCcmmdlZquv0`91%JkM>}Qk}5fz@Ei!c!d6aF^8fZ@U(BhX@lZImOA{sM++ zGK#VqriKj`kSf2en&-{<7%#>t7E)(T626JbL4?nXO-BSV#O>ISOE3eLqr#LwF-rh| zYB*dFSlcE9;cpR=!tdkWs8|X7pAzr-cdQgSAIG#pqT!EL=sf>L?85VB5JO3}gTmL- zA-tQGLNYkbT3k7OJ;D`CWLT7Ub+_r_p`+}E8aYu@4eSN zQpJQ3VBT58hTSEuFN2nS1-_goWg|2of>P`ca5rYr-hosw)^Z<<=M&4DVmW!%B}{}6 z7%jvH9rKmOM?!=EDWMsbbq^R9X)(0z379hXt*Nk>Dfg1Gl52>H&J%8Cs}j%VM2r; zTA?wz6BU>;P284p>{Zb6=eWsK0!;T-K1@{5BOKr*`Z^EMQV_$_7)H#@M10T(588eq z$Y3c*ZXt+ZfYTT&ePY8G!uB9{{i5*TR@fozwE|UN8sPjS8&BK&0t_w^CIqZ+*oX=q zHz?C`5Z}818fDBns3Rr<3=4PIh-a~l#&O|vc%c~n&cfd^Y{&JK7)sze=G0uw3Q`ow zx6&}Q^@sRt2=6sWG&`ZHOK&1pbWTE9vnEpl!v(zTamVTS$R~b)r0Pr4%zV$i#CKk2xpjCxmTI+hV7v@T5rQZ@w2tn;3WtGf z)8p0JKR*93(yHI>AEK^fARyL)@U574?|REL6>jP(ki+Y6lKU3!%@S5iJ4_Qxj=&3_ z2h+7#&zi z#y*lJ1Sq≶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 client_secret: <%= ENV["OMNIAUTH_PUBLIK_CLIENT_SECRET"] %> site_url: <%= ENV["OMNIAUTH_PUBLIK_SITE_URL"] %> + cultuur_connect: + enabled: false + icon_path: <%= ENV["OMNIAUTH_CCO_ICON_PATH"] %> + client_id: <%= ENV["OMNIAUTH_CCO_CLIENT_ID"] %> + client_secret: <%= ENV["OMNIAUTH_CCO_CLIENT_SECRET"] %> + site_url: <%= ENV["OMNIAUTH_SITE_URL"] %> france_connect: enabled: <%= ENV["OMNIAUTH_FC_CLIENT_SECRET"].present? %> client_id: <%= ENV["OMNIAUTH_FC_CLIENT_ID"] %> diff --git a/lib/omniauth/strategies/cultuur_connect.rb b/lib/omniauth/strategies/cultuur_connect.rb new file mode 100644 index 0000000000..6e3f6cfed5 --- /dev/null +++ b/lib/omniauth/strategies/cultuur_connect.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "omniauth-oauth2" + +module OmniAuth + module Strategies + class CultuurConnect < OmniAuth::Strategies::OAuth2 + option :name, :cultuur_connect + option :client_options, { + authorize_url: "/idp/rest/auth", + token_url: "/idp/rest/auth/token", + logout_url: "/idp/rest/auth/logout" + } + option :provider_ignores_state, true + + uid { raw_info["sub"] } + + info do + Rails.logger.debug raw_info.inspect + { + name: find_name, + email: raw_info["email"], + nickname: find_nickname, + firstname: raw_info["firstname"], + surname: raw_info["surname"] + } + end + + extra { { "raw_info" => raw_info } } + + def raw_info + @raw_info ||= ::JWT.decode(access_token.token, nil, false)[0] + end + + def find_name + [raw_info["firstname"], raw_info["surname"], raw_info["familyname"]].compact.join(" ").strip + end + + def find_nickname + ::Decidim::UserBaseEntity.nicknamize(find_name) + end + + protected + + def build_access_token + @build_access_token ||= client.auth_code.get_token( + request.params["code"], + { redirect_uri: callback_url, client_id: options.client_id, client_secret: options.client_secret } + .merge(token_params.to_hash(symbolize_keys: true)), + deep_symbolize(options.auth_token_params) + ) + rescue ::OAuth2::Error => e + handle_token_error(e) + end + + private + + def handle_token_error(error) + raise error unless error.try(:response)&.parsed + + @handle_token_error ||= (::JWT.decode error.response.parsed["idToken"], nil, false)[0] + end + end + end +end diff --git a/spec/lib/omniauth/strategies/cultuur_connect_spec.rb b/spec/lib/omniauth/strategies/cultuur_connect_spec.rb new file mode 100644 index 0000000000..a3841273d2 --- /dev/null +++ b/spec/lib/omniauth/strategies/cultuur_connect_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "omniauth/strategies/cultuur_connect" +require "spec_helper" + +class DummyApp + def call(env); end +end + +module OmniAuth + module Strategies + RSpec.describe CultuurConnect do + subject do + described_class.new(DummyApp.new).tap do |strategy| + strategy.options.client_options.site = site + strategy.options.client_id = "dummy_client_id" + strategy.options.client_secret = "dummy_client_secret" + end + end + let(:site) { "https://example.com" } + + it "returns correct strategy name" do + expect(subject.options.name).to eq(:cultuur_connect) + end + + it "returns option site" do + expect(subject.options.client_options.site).to eq(site) + end + + it "returns client_id" do + expect(subject.options.client_id).to eq("dummy_client_id") + end + + it "returns client_secret" do + expect(subject.options.client_secret).to eq("dummy_client_secret") + end + + describe "#build_access_token" do + it "builds access token with correct params" do + allow(subject).to receive(:callback_url).and_return("https://example.com/callback") + allow(subject).to receive(:request).and_return(double(params: { "code" => "dummy_code" })) + allow(subject).to receive(:client).and_return(double(auth_code: double(get_token: "dummy_token"))) + + token = subject.send(:build_access_token) + expect(token).to eq("dummy_token") + end + end + + describe "#raw_info" do + it "decodes JWT token correctly" do + allow(subject).to receive(:access_token).and_return(double(token: "dummy_jwt_token")) + allow(JWT).to receive(:decode).and_return([{ "sub" => "123", "email" => "test@example.com" }]) + + raw_info = subject.send(:raw_info) + expect(raw_info["sub"]).to eq("123") + expect(raw_info["email"]).to eq("test@example.com") + end + end + + describe "#info" do + it "returns correct user info" do + allow(subject).to receive(:raw_info).and_return({ "sub" => "123", "email" => "test@example.com", "firstname" => "John", "surname" => "Doe" }) + + info = subject.send(:info) + expect(info[:name]).to eq("John Doe") + expect(info[:email]).to eq("test@example.com") + expect(info[:nickname]).to eq("john_doe") + end + end + end + end +end From 6a7b17e99af892970c9666b46d24204cda5047ba Mon Sep 17 00:00:00 2001 From: Guillaume MORET <90462045+AyakorK@users.noreply.github.com> Date: Thu, 27 Feb 2025 15:59:32 +0100 Subject: [PATCH 19/19] fix: Change the dynamic maps loading using OpenStreetMap (#685) * fix: Change the dynamic maps loading using OpenStreetMap * fix: Remove config entry to enforce OSM default over HERE --------- Co-authored-by: Lucie Grau <87868063+luciegrau@users.noreply.github.com> --- config/initializers/decidim.rb | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/config/initializers/decidim.rb b/config/initializers/decidim.rb index 4ad75b1dde..e8d1202b62 100644 --- a/config/initializers/decidim.rb +++ b/config/initializers/decidim.rb @@ -37,9 +37,21 @@ config.maps = { provider: :here, api_key: Rails.application.secrets.maps[:api_key], - static: { url: "https://image.maps.ls.hereapi.com/mia/1.6/mapview" }, + + # Keep HERE as the default provider for autocomplete autocomplete: { address_format: [%w(houseNumber street), "city", "country"] + }, + + # Change to OSM for dynamic maps to avoid usage limits from HERE + dynamic: { + provider: :osm, + tile_layer: { + url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + attribution: %( + © OpenStreetMap contributors + ) + } } }