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/.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/Gemfile b/Gemfile index dbd5d63799..4ffc6a84b2 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" @@ -42,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" + +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" @@ -51,6 +54,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" diff --git a/Gemfile.lock b/Gemfile.lock index 47d849123e..bf25855409 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 @@ -42,7 +51,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 +81,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 +133,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 @@ -364,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) @@ -612,6 +622,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 +706,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 +857,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 +908,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 +1092,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 +1116,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 +1139,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) @@ -1175,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! @@ -1215,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 @@ -1235,4 +1250,4 @@ RUBY VERSION ruby 3.0.6p216 BUNDLED WITH - 2.5.22 + 2.5.10 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/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/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/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 %> 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 0000000000..f92468fb03 Binary files /dev/null and b/app/packs/images/cultuur-connect-logo@2x.png differ 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(); + }) + } + } 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/i18n-tasks.yml b/config/i18n-tasks.yml index dc6ebdae36..e0058871e4 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -144,6 +144,7 @@ ignore_unused: - decidim.system.organizations.omniauth_settings.{france_connect, france_connect_profile, france_connect_uid}.* - decidim.system.organizations.omniauth_settings.openid_connect.* - decidim.system.organizations.omniauth_settings.publik.* + - decidim.system.organizations.omniauth_settings.cultuur_connect.* - decidim.verifications.authorizations.create.* - decidim.verifications.authorizations.first_login.actions.* - rack_attack.too_many_requests.* @@ -170,5 +171,5 @@ ignore_unused: - decidim.system.titles.info.* - decidim.budgets.projects.orders.* - decidim.components.budgets.settings.* - + - decidim.admin_multi_factor.verification_code_mailer.verification_code.* 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 + ) + } } } 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_cultuur_connect.rb b/config/initializers/omniauth_cultuur_connect.rb new file mode 100644 index 0000000000..a5f738d7d6 --- /dev/null +++ b/config/initializers/omniauth_cultuur_connect.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "omniauth/strategies/cultuur_connect" + +Rails.application.config.middleware.use OmniAuth::Builder do + provider( + :cultuur_connect, + setup: lambda { |env| + request = Rack::Request.new(env) + organization = env["decidim.current_organization"].presence || Decidim::Organization.find_by(host: request.host) + provider_config = organization.enabled_omniauth_providers[:cultuur_connect] + env["omniauth.strategy"].options[:client_id] = provider_config[:client_id] + env["omniauth.strategy"].options[:client_secret] = provider_config[:client_secret] + env["omniauth.strategy"].options[:client_options][:site] = provider_config[:site_url] + } + ) +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/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 ffa0ec09a7..3924a0fcb1 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 @@ -44,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: @@ -246,6 +270,10 @@ en: current_organizations: Current organizations organizations: omniauth_settings: + cultuur_connect: + client_id: Client ID + client_secret: Client secret + site_url: Site URL france_connect: client_id: Client ID client_secret: Client secret @@ -271,6 +299,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..b1c9e93b4c 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 @@ -46,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: @@ -248,6 +272,10 @@ fr: current_organizations: Organisations organizations: omniauth_settings: + cultuur_connect: + client_id: Client ID + client_secret: Client secret + site_url: Site URL france_connect: client_id: Client ID client_secret: Client secret @@ -273,6 +301,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..f0b23af811 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: @@ -93,6 +95,12 @@ default: &default client_id: <%= ENV["OMNIAUTH_PUBLIK_CLIENT_ID"] %> 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"] %> @@ -109,6 +117,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/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" 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..18063fed05 --- /dev/null +++ b/lib/extends/controllers/decidim/devise/omniauth_registrations_controller_extends.rb @@ -0,0 +1,73 @@ +# 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 + + 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 verified_email + @verified_email ||= oauth_data.dig(:info, :email) || session.delete("devise.omniauth.verified_email") + end + + 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/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/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 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 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 diff --git a/spec/services/deepl_translator_spec.rb b/spec/services/deepl_translator_spec.rb index 457f3469c6..b0ecc5d658 100644 --- a/spec/services/deepl_translator_spec.rb +++ b/spec/services/deepl_translator_spec.rb @@ -17,6 +17,7 @@ headers: { "Accept" => "*/*", "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", + "Authorization" => "DeepL-Auth-Key dummy_key", "User-Agent" => "Ruby" } ).to_return(status: 200, body: JSON.dump([ @@ -182,6 +183,7 @@ 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/x-www-form-urlencoded", "User-Agent" => "Ruby" } 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