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 @@
+
+
\ 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:
+
+
+ - Name
+ - Nickname
+ - Email
+
+
+ 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:
+
+
+ - Nom
+ - Pseudonyme
+ - Email
+
+
+ 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