decidim-core 0.31.7 → 0.31.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/app/cells/decidim/content_blocks/participatory_space_main_data/title.erb +1 -14
  3. data/app/cells/decidim/following_cell.rb +1 -1
  4. data/app/cells/decidim/nav_links/show.erb +3 -1
  5. data/app/cells/decidim/participatory_space_private_user/show.erb +1 -1
  6. data/app/cells/decidim/share_text_widget/modal.erb +1 -0
  7. data/app/cells/decidim/share_widget/modal.erb +1 -0
  8. data/app/cells/decidim/share_widget/qr_code_modal.erb +2 -4
  9. data/app/commands/decidim/create_omniauth_registration.rb +17 -2
  10. data/app/controllers/concerns/decidim/devise_authentication_methods.rb +4 -0
  11. data/app/controllers/concerns/decidim/devise_controllers.rb +2 -0
  12. data/app/controllers/concerns/decidim/disable_redirection_to_external_host.rb +2 -0
  13. data/app/controllers/concerns/decidim/filter_resource.rb +2 -0
  14. data/app/controllers/concerns/decidim/impersonate_users.rb +2 -2
  15. data/app/controllers/concerns/decidim/locale_switcher.rb +62 -11
  16. data/app/controllers/concerns/decidim/needs_permission.rb +22 -10
  17. data/app/controllers/concerns/decidim/onboarding_action_methods.rb +2 -0
  18. data/app/controllers/concerns/decidim/paginable.rb +7 -3
  19. data/app/controllers/concerns/decidim/payload_info.rb +2 -0
  20. data/app/controllers/concerns/decidim/resource_versions_concern.rb +14 -0
  21. data/app/controllers/decidim/application_controller.rb +0 -6
  22. data/app/controllers/decidim/authorization_modals_controller.rb +2 -0
  23. data/app/controllers/decidim/components/base_controller.rb +2 -0
  24. data/app/controllers/decidim/devise/passwords_controller.rb +8 -0
  25. data/app/controllers/decidim/favicon_controller.rb +9 -0
  26. data/app/controllers/decidim/free_resource_authorization_modals_controller.rb +2 -0
  27. data/app/controllers/decidim/gamification/badges_controller.rb +2 -0
  28. data/app/controllers/decidim/homepage_controller.rb +4 -1
  29. data/app/controllers/decidim/likes_controller.rb +1 -1
  30. data/app/controllers/decidim/links_controller.rb +2 -0
  31. data/app/controllers/decidim/manifests_controller.rb +2 -0
  32. data/app/controllers/decidim/messaging/conversations_controller.rb +10 -4
  33. data/app/controllers/decidim/newsletters_controller.rb +4 -0
  34. data/app/controllers/decidim/notifications_subscriptions_controller.rb +4 -0
  35. data/app/controllers/decidim/offline_controller.rb +5 -1
  36. data/app/controllers/decidim/open_data_controller.rb +5 -1
  37. data/app/controllers/decidim/pages_controller.rb +31 -3
  38. data/app/controllers/decidim/private_downloads_controller.rb +2 -0
  39. data/app/controllers/decidim/profiles_controller.rb +10 -0
  40. data/app/controllers/decidim/qr_controller.rb +3 -1
  41. data/app/controllers/decidim/resource_autocomplete_controller.rb +2 -0
  42. data/app/controllers/decidim/searches_controller.rb +2 -0
  43. data/app/controllers/decidim/short_links_controller.rb +4 -0
  44. data/app/controllers/decidim/static_map_controller.rb +2 -0
  45. data/app/controllers/decidim/timeouts_controller.rb +4 -0
  46. data/app/controllers/decidim/tos_controller.rb +2 -0
  47. data/app/controllers/decidim/upload_validations_controller.rb +4 -2
  48. data/app/controllers/decidim/user_activities_controller.rb +2 -0
  49. data/app/forms/decidim/upload_validation_form.rb +16 -10
  50. data/app/helpers/concerns/decidim/user_role_checker.rb +35 -10
  51. data/app/helpers/decidim/sanitize_helper.rb +11 -1
  52. data/app/jobs/decidim/application_job.rb +4 -0
  53. data/app/models/decidim/authorization.rb +24 -0
  54. data/app/models/decidim/oauth_application.rb +1 -0
  55. data/app/models/decidim/user.rb +1 -0
  56. data/app/models/decidim/user_base_entity.rb +1 -1
  57. data/app/packs/src/decidim/callout.js +10 -0
  58. data/app/packs/src/decidim/callout.test.js +32 -0
  59. data/app/packs/src/decidim/controllers/accordion/accordion.test.js +64 -0
  60. data/app/packs/src/decidim/controllers/accordion/controller.js +43 -0
  61. data/app/packs/src/decidim/datepicker/datepicker_functions.js +34 -23
  62. data/app/packs/src/decidim/datepicker/generate_datepicker.js +4 -4
  63. data/app/packs/src/decidim/datepicker/test/time.test.js +39 -24
  64. data/app/packs/src/decidim/utilities/dom.js +2 -2
  65. data/app/packs/stylesheets/decidim/_content_blocks.scss +4 -10
  66. data/app/packs/stylesheets/decidim/_conversations.scss +0 -8
  67. data/app/packs/stylesheets/decidim/_modal.scss +16 -8
  68. data/app/packs/stylesheets/decidim/_modal_share.scss +0 -22
  69. data/app/packs/stylesheets/decidim/_participatory_spaces.scss +11 -1
  70. data/app/packs/stylesheets/decidim/_rich_text.scss +12 -9
  71. data/app/packs/stylesheets/decidim/_typography.scss +5 -2
  72. data/app/packs/stylesheets/decidim/legacy/email.scss +0 -5
  73. data/app/permissions/decidim/permissions.rb +20 -0
  74. data/app/presenters/decidim/admin_log/component_presenter.rb +1 -1
  75. data/app/presenters/decidim/log/base_presenter.rb +10 -1
  76. data/app/presenters/decidim/log/user_presenter.rb +2 -2
  77. data/app/serializers/decidim/exporters/open_data_blocked_user_serializer.rb +11 -2
  78. data/app/serializers/decidim/exporters/open_data_moderation_serializer.rb +25 -1
  79. data/app/services/decidim/svg_content_validator.rb +554 -0
  80. data/app/uploaders/decidim/application_uploader.rb +2 -2
  81. data/app/validators/uploader_image_content_validator.rb +278 -0
  82. data/app/views/decidim/application/_radio_accordion.html.erb +1 -5
  83. data/app/views/decidim/messaging/conversations/create.js.erb +1 -3
  84. data/app/views/decidim/shared/_login_modal.html.erb +12 -14
  85. data/app/views/layouts/decidim/_mailer_logo.html.erb +1 -1
  86. data/config/initializers/rack_attack.rb +10 -0
  87. data/config/locales/bg.yml +0 -1
  88. data/config/locales/ca-IT.yml +3 -1
  89. data/config/locales/ca.yml +3 -1
  90. data/config/locales/cs.yml +7 -1
  91. data/config/locales/de.yml +61 -3
  92. data/config/locales/en.yml +3 -1
  93. data/config/locales/es-MX.yml +3 -1
  94. data/config/locales/es-PY.yml +3 -1
  95. data/config/locales/es.yml +7 -5
  96. data/config/locales/eu.yml +155 -156
  97. data/config/locales/fi-plain.yml +1 -1
  98. data/config/locales/fi.yml +1 -1
  99. data/config/locales/fr-CA.yml +3 -1
  100. data/config/locales/fr.yml +3 -1
  101. data/config/locales/gl.yml +1441 -33
  102. data/config/locales/hu.yml +0 -1
  103. data/config/locales/ja.yml +1 -1
  104. data/config/locales/pl.yml +0 -1
  105. data/config/locales/pt-BR.yml +0 -1
  106. data/config/locales/ro-RO.yml +0 -1
  107. data/config/locales/sk.yml +2 -1
  108. data/config/locales/sv.yml +47 -10
  109. data/config/locales/tr-TR.yml +0 -2
  110. data/db/migrate/20260909000000_add_failed_attempts_and_locked_at_to_decidim_authorizations.rb +8 -0
  111. data/lib/decidim/core/test/shared_examples/participatory_space_members_page_examples.rb +77 -0
  112. data/lib/decidim/core/test/shared_examples/participatory_space_members_shared_examples.rb +85 -6
  113. data/lib/decidim/core/test/shared_examples/versions_controller_examples.rb +23 -1
  114. data/lib/decidim/core/version.rb +1 -1
  115. data/lib/decidim/core.rb +9 -0
  116. data/lib/decidim/has_upload_validations.rb +1 -0
  117. data/lib/decidim/legacy_form_builder.rb +4 -0
  118. data/lib/decidim/query_extensions.rb +2 -0
  119. metadata +10 -6
@@ -4,9 +4,13 @@ module Decidim
4
4
  class OpenDataController < Decidim::ApplicationController
5
5
  helper_method :open_data_component_manifests, :open_data_participatory_space_manifests, :open_data_core_manifests
6
6
 
7
- def index; end
7
+ def index
8
+ enforce_permission_to :read, :open_data
9
+ end
8
10
 
9
11
  def download
12
+ enforce_permission_to :read, :open_data
13
+
10
14
  resource = params[:resource] || nil
11
15
 
12
16
  if open_data_file_for_resource(resource)
@@ -10,6 +10,10 @@ module Decidim
10
10
 
11
11
  before_action :legacy_redirect, :set_default_request_format
12
12
 
13
+ # Included after the legacy redirect so its before_action does not look the
14
+ # page up (and 404) before the redirect happens.
15
+ include HasSpecificBreadcrumb
16
+
13
17
  def index
14
18
  enforce_permission_to :read, :public_page
15
19
  @topics = StaticPageTopic.where(organization: current_organization)
@@ -17,14 +21,38 @@ module Decidim
17
21
  end
18
22
 
19
23
  def show
20
- @page = current_organization.static_pages.find_by!(slug: params[:id])
21
- enforce_permission_to :read, :public_page, page: @page
22
- @topic = @page.topic
24
+ enforce_permission_to :read, :public_page, page:
25
+
26
+ @topic = page.topic
23
27
  @pages = @topic&.pages
24
28
  end
25
29
 
26
30
  private
27
31
 
32
+ def page
33
+ @page ||= current_organization.static_pages.find_by!(slug: params[:id])
34
+ end
35
+
36
+ def breadcrumb_item
37
+ items = [
38
+ {
39
+ label: t("decidim.pages.index.title"),
40
+ active: action_name == "index",
41
+ url: pages_path
42
+ }
43
+ ]
44
+
45
+ if action_name == "show"
46
+ items << {
47
+ label: page.title,
48
+ active: true,
49
+ url: page_path(page)
50
+ }
51
+ end
52
+
53
+ items
54
+ end
55
+
28
56
  def legacy_redirect
29
57
  return redirect_to decidim.page_path("terms-of-service") if params[:id] == "terms-and-conditions"
30
58
  end
@@ -5,6 +5,8 @@ module Decidim
5
5
  before_action :authenticate_user!
6
6
 
7
7
  def show
8
+ enforce_permission_to :read, :private_download
9
+
8
10
  return head :not_found unless private_download.attached?
9
11
  return head :not_found unless private_download.authorized_for?(current_user)
10
12
 
@@ -17,28 +17,38 @@ module Decidim
17
17
  before_action :ensure_user_not_blocked
18
18
 
19
19
  def show
20
+ enforce_permission_to :read, :profile
21
+
20
22
  redirect_to profile_activity_path(nickname: params[:nickname].downcase)
21
23
  end
22
24
 
23
25
  def following
26
+ enforce_permission_to :read, :profile
27
+
24
28
  @content_cell = "decidim/following"
25
29
  @title_key = "following"
26
30
  render :show
27
31
  end
28
32
 
29
33
  def followers
34
+ enforce_permission_to :read, :profile
35
+
30
36
  @content_cell = "decidim/followers"
31
37
  @title_key = "followers"
32
38
  render :show
33
39
  end
34
40
 
35
41
  def badges
42
+ enforce_permission_to :read, :profile
43
+
36
44
  @content_cell = "decidim/badges"
37
45
  @title_key = "badges"
38
46
  render :show
39
47
  end
40
48
 
41
49
  def activity
50
+ enforce_permission_to :read, :profile
51
+
42
52
  @content_cell = "decidim/user_activity"
43
53
  @title_key = "activity"
44
54
  render :show
@@ -7,7 +7,7 @@ module Decidim
7
7
  include Decidim::OrganizationHelper
8
8
  include Decidim::QrCodeHelper
9
9
 
10
- helper_method :resource, :qr_code, :qr_code_image
10
+ helper_method :resource, :qr_code, :qr_code_image, :current_component, :current_participatory_space
11
11
  before_action :validate_resource
12
12
  before_action :validate_component
13
13
  before_action :validate_participatory_space
@@ -15,6 +15,8 @@ module Decidim
15
15
  layout false
16
16
 
17
17
  def show
18
+ enforce_permission_to :read, :qr_code
19
+
18
20
  respond_to do |format|
19
21
  format.html
20
22
  format.png { send_data(qr_code.as_png(size: 500), filename: "qr-#{organization_name}-#{parametrized_title}.png") }
@@ -3,6 +3,8 @@
3
3
  module Decidim
4
4
  class ResourceAutocompleteController < Decidim::ApplicationController
5
5
  def index
6
+ enforce_permission_to :read, :search
7
+
6
8
  respond_to do |format|
7
9
  format.json do
8
10
  if term.blank?
@@ -12,6 +12,8 @@ module Decidim
12
12
  helper_method :term
13
13
 
14
14
  def index
15
+ enforce_permission_to :read, :search
16
+
15
17
  Search.call(term, current_organization, filters, page_params) do
16
18
  on(:ok) do |results|
17
19
  results_count = results.sum { |results_by_type| results_by_type.last[:count] }
@@ -8,6 +8,8 @@ module Decidim
8
8
  # The index action redirects the user to the application root in case
9
9
  # someone tries to request the short link URL without an identifier.
10
10
  def index
11
+ enforce_permission_to :read, :short_link
12
+
11
13
  redirect_to decidim.root_path, status: :moved_permanently
12
14
  end
13
15
 
@@ -18,6 +20,8 @@ module Decidim
18
20
  # @raise [ActionController::RoutingError] if a short link does not exist
19
21
  # with the given identifier
20
22
  def show
23
+ enforce_permission_to :read, :short_link
24
+
21
25
  raise ActionController::RoutingError, "Not Found" unless link
22
26
 
23
27
  redirect_to link.target_url, status: :moved_permanently
@@ -5,6 +5,8 @@ module Decidim
5
5
  skip_before_action :store_current_location
6
6
 
7
7
  def show
8
+ enforce_permission_to :read, :static_map
9
+
8
10
  send_data StaticMapGenerator.new(resource).data, type: "image/jpeg", disposition: "inline"
9
11
  end
10
12
 
@@ -13,6 +13,8 @@ module Decidim
13
13
  prepend_before_action :skip_timeout, only: :seconds_until_timeout
14
14
 
15
15
  def seconds_until_timeout
16
+ enforce_permission_to :read, :timeout
17
+
16
18
  time_remaining = current_user ? Decidim.config.expire_session_after - (Time.current - Time.zone.at(user_session["last_request_at"])) : 0
17
19
  respond_to do |format|
18
20
  format.json { render json: { seconds_remaining: time_remaining.to_i }, status: :ok }
@@ -21,6 +23,8 @@ module Decidim
21
23
 
22
24
  # If user wants to continue session after inactivity warning.
23
25
  def heartbeat
26
+ enforce_permission_to :read, :timeout
27
+
24
28
  respond_to do |format|
25
29
  format.js
26
30
  end
@@ -7,6 +7,8 @@ module Decidim
7
7
  skip_before_action :store_current_location
8
8
 
9
9
  def accept_tos
10
+ enforce_permission_to(:update, :user, current_user:)
11
+
10
12
  current_user.accepted_tos_version = Time.current
11
13
  if current_user.save!
12
14
  flash[:notice] = t("accept.success", scope: "decidim.pages.terms_of_service")
@@ -10,6 +10,8 @@ module Decidim
10
10
  before_action :underscore_params!
11
11
 
12
12
  def create
13
+ enforce_permission_to :create, :upload_validation
14
+
13
15
  @form = form(Decidim::UploadValidationForm).from_params(params)
14
16
 
15
17
  ValidateUpload.call(@form) do
@@ -23,14 +25,14 @@ module Decidim
23
25
  end
24
26
  end
25
27
 
28
+ private
29
+
26
30
  def organization_time_zone
27
31
  return Rails.application.config.time_zone unless current_organization
28
32
 
29
33
  super
30
34
  end
31
35
 
32
- private
33
-
34
36
  def ensure_authenticated!
35
37
  return true unless current_organization
36
38
 
@@ -12,6 +12,8 @@ module Decidim
12
12
  helper_method :activities, :resource_types, :user
13
13
 
14
14
  def index
15
+ enforce_permission_to :read, :profile
16
+
15
17
  raise ActionController::RoutingError, "Missing user: #{params[:nickname]}" unless user
16
18
  raise ActionController::RoutingError, "Blocked User" if user.blocked? && !current_user&.admin?
17
19
  end
@@ -32,16 +32,22 @@ module Decidim
32
32
  private
33
33
 
34
34
  def file_validators
35
- org = organization
36
- PassthruValidator.new(
37
- attributes: [property],
38
- to: resource_class.constantize,
39
- with: lambda { |record|
40
- validate_with.tap do |hash|
41
- hash.merge!(organization: record.try(:organization) || org) if !hash[:organization] && record.respond_to?(:organization=)
42
- end
43
- }
44
- ).validate_each(self, property.to_sym, blob)
35
+ if resource_class.constantize == Decidim::ContentBlockAttachment && resource_class.constantize.validators_on(property.to_sym).none?
36
+ extension = blob.filename.to_s.split(".").last&.downcase
37
+ allowed = Decidim::RecordImageUploader.new(nil, :file).extension_allowlist
38
+ errors.add(property.to_sym, I18n.t("errors.messages.allowed_file_content_types", types: allowed.join(", "))) if extension.present? && allowed.exclude?(extension)
39
+ else
40
+ org = organization
41
+ PassthruValidator.new(
42
+ attributes: [property],
43
+ to: resource_class.constantize,
44
+ with: lambda { |record|
45
+ validate_with.tap do |hash|
46
+ hash.merge!(organization: record.try(:organization) || org) if !hash[:organization] && record.respond_to?(:organization=)
47
+ end
48
+ }
49
+ ).validate_each(self, property.to_sym, blob)
50
+ end
45
51
  end
46
52
 
47
53
  def validate_with
@@ -10,35 +10,60 @@ module Decidim
10
10
  def user_has_any_role?(user, participatory_space = nil, broad_check: false)
11
11
  return false unless user
12
12
 
13
- [
14
- user.admin,
15
- user.roles.any?,
16
- participatory_process_user_role?(user, participatory_space, broad_check:),
17
- assembly_user_role?(user, participatory_space, broad_check:),
18
- conference_user_role?(user, participatory_space, broad_check:)
19
- ].any?
13
+ @user_has_any_role_cache ||= {}
14
+ cache_key = [user.id, participatory_space&.class&.name, participatory_space&.id, broad_check]
15
+ return @user_has_any_role_cache[cache_key] if @user_has_any_role_cache.has_key?(cache_key)
16
+
17
+ @user_has_any_role_cache[cache_key] = global_user_role?(user) || space_user_role?(user, participatory_space, broad_check:)
18
+ end
19
+
20
+ def global_user_role?(user)
21
+ user.admin || user.roles.any?
22
+ end
23
+
24
+ def space_user_role?(user, participatory_space, broad_check: false)
25
+ return participatory_process_user_role?(user, participatory_space, broad_check:) if participatory_process_space?(participatory_space)
26
+ return assembly_user_role?(user, participatory_space, broad_check:) if assembly_space?(participatory_space)
27
+ return conference_user_role?(user, participatory_space, broad_check:) if conference_space?(participatory_space)
28
+ return false unless broad_check
29
+
30
+ participatory_process_user_role?(user, nil, broad_check: true) ||
31
+ assembly_user_role?(user, nil, broad_check: true) ||
32
+ conference_user_role?(user, nil, broad_check: true)
33
+ end
34
+
35
+ def participatory_process_space?(participatory_space)
36
+ Decidim.module_installed?(:participatory_processes) && participatory_space.is_a?(Decidim::ParticipatoryProcess)
37
+ end
38
+
39
+ def assembly_space?(participatory_space)
40
+ Decidim.module_installed?(:assemblies) && participatory_space.is_a?(Decidim::Assembly)
41
+ end
42
+
43
+ def conference_space?(participatory_space)
44
+ Decidim.module_installed?(:conferences) && participatory_space.is_a?(Decidim::Conference)
20
45
  end
21
46
 
22
47
  def participatory_process_user_role?(user, participatory_process = nil, broad_check: false)
23
48
  return false unless Decidim.module_installed?(:participatory_processes)
49
+ return false unless participatory_process.nil? || participatory_process.is_a?(Decidim::ParticipatoryProcess)
24
50
  return Decidim::ParticipatoryProcessUserRole.exists?(user:) if broad_check
25
- return false unless participatory_process.is_a?(Decidim::ParticipatoryProcess)
26
51
 
27
52
  Decidim::ParticipatoryProcessUserRole.exists?(user:, participatory_process:)
28
53
  end
29
54
 
30
55
  def assembly_user_role?(user, assembly = nil, broad_check: false)
31
56
  return false unless Decidim.module_installed?(:assemblies)
57
+ return false unless assembly.nil? || assembly.is_a?(Decidim::Assembly)
32
58
  return Decidim::AssemblyUserRole.exists?(user:) if broad_check
33
- return false unless assembly.is_a?(Decidim::Assembly)
34
59
 
35
60
  Decidim::AssemblyUserRole.exists?(user:, assembly:)
36
61
  end
37
62
 
38
63
  def conference_user_role?(user, conference = nil, broad_check: false)
39
64
  return false unless Decidim.module_installed?(:conferences)
65
+ return false unless conference.nil? || conference.is_a?(Decidim::Conference)
40
66
  return Decidim::ConferenceUserRole.exists?(user:) if broad_check
41
- return false unless conference.is_a?(Decidim::Conference)
42
67
 
43
68
  Decidim::ConferenceUserRole.exists?(user:, conference:)
44
69
  end
@@ -55,8 +55,18 @@ module Decidim
55
55
  )
56
56
  end
57
57
 
58
+ # Public: Escapes the HTML special characters of a user-inputted string.
59
+ #
60
+ # The result is marked as HTML-safe, so that when the views output it with
61
+ # `<%= %>` (or pass it to a tag helper) Rails does not escape it a second
62
+ # time. Otherwise, a text such as `O'Neil` would end up as `O&amp;#39;Neil` in
63
+ # the HTML and the browser would display the `&#39;` entity literally.
64
+ #
65
+ # text - A String (or an object responding to `to_str`).
66
+ #
67
+ # Returns an HTML-safe String.
58
68
  def decidim_html_escape(text)
59
- ERB::Util.unwrapped_html_escape(text.to_str)
69
+ ERB::Util.html_escape(text.to_str)
60
70
  end
61
71
 
62
72
  def decidim_url_escape(text)
@@ -2,6 +2,10 @@
2
2
 
3
3
  module Decidim
4
4
  class ApplicationJob < ActiveJob::Base
5
+ # Wait until the commits on the transaction are actually on the database
6
+ # before running the jobs, preventing possible race conditions.
7
+ self.enqueue_after_transaction_commit = true
8
+
5
9
  # Automatically retry jobs that encountered a deadlock
6
10
  retry_on ActiveRecord::Deadlocked
7
11
 
@@ -98,6 +98,30 @@ module Decidim
98
98
  user == self.user
99
99
  end
100
100
 
101
+ def record_failed_attempt!
102
+ with_lock do
103
+ increment!(:failed_attempts) # rubocop:disable Rails/SkipsModelValidations
104
+ return unless failed_attempts >= Decidim.verification_max_failed_attempts
105
+
106
+ update!(locked_at: Time.current)
107
+ end
108
+ end
109
+
110
+ def reset_failed_attempts!
111
+ update!(failed_attempts: 0, locked_at: nil)
112
+ end
113
+
114
+ def locked_for_confirmation?
115
+ locked_at.present? && locked_at + Decidim.verification_unlock_in > Time.current
116
+ end
117
+
118
+ def clear_expired_lock!
119
+ return if locked_at.blank?
120
+ return if locked_for_confirmation?
121
+
122
+ update!(locked_at: nil, failed_attempts: 0)
123
+ end
124
+
101
125
  private
102
126
 
103
127
  def active_handler?
@@ -18,6 +18,7 @@ module Decidim
18
18
  :organization_logo,
19
19
  file_size: { less_than_or_equal_to: ->(record) { record.maximum_upload_size } },
20
20
  uploader_content_type: true,
21
+ uploader_image_content: true,
21
22
  uploader_image_dimensions: true
22
23
  )
23
24
  attached_config[:organization_logo] = OpenStruct.new(uploader: OAuthApplicationLogoUploader)
@@ -32,6 +32,7 @@ module Decidim
32
32
  has_many :access_tokens, class_name: "Doorkeeper::AccessToken", foreign_key: :resource_owner_id, dependent: :destroy
33
33
  has_many :reminders, foreign_key: "decidim_user_id", class_name: "Decidim::Reminder", dependent: :destroy
34
34
  has_many :private_exports, class_name: "Decidim::PrivateExport", dependent: :destroy, inverse_of: :attached_to, as: :attached_to
35
+ has_many :impersonation_logs, foreign_key: "decidim_user_id", class_name: "Decidim::ImpersonationLog", dependent: :destroy
35
36
 
36
37
  validates :name, presence: true, unless: -> { deleted? }
37
38
  validates :nickname,
@@ -62,7 +62,7 @@ module Decidim
62
62
  end
63
63
 
64
64
  def users_followings
65
- @users_followings ||= Decidim::UserBaseEntity.joins(:follows).where(decidim_follows: { user: self })
65
+ @users_followings ||= Decidim::UserBaseEntity.joins(:follows).where(decidim_follows: { user: self }).order(decidim_follows: { created_at: :asc, id: :asc })
66
66
  end
67
67
 
68
68
  def followings_blocked?
@@ -19,3 +19,13 @@ document.addEventListener("turbo:load", () => {
19
19
  callout.innerHTML += "&nbsp;";
20
20
  }, 500);
21
21
  });
22
+
23
+ document.addEventListener("click", (event) => {
24
+ const closeButton = event.target.closest("[data-alert-box] [data-close]");
25
+
26
+ if (!closeButton) {
27
+ return;
28
+ }
29
+
30
+ closeButton.closest("[data-alert-box]").remove();
31
+ });
@@ -0,0 +1,32 @@
1
+ import "src/decidim/callout"
2
+
3
+ describe("callout close button", () => {
4
+ beforeEach(() => {
5
+ document.body.innerHTML = `
6
+ <div id="flash-messages-container">
7
+ <div class="flash success" data-alert-box role="alert" aria-atomic="true">
8
+ <div class="flash__message">Signed in successfully.</div>
9
+ <button type="button" class="close-button" data-close aria-label="Dismiss">×</button>
10
+ </div>
11
+ </div>
12
+ `;
13
+ });
14
+
15
+ it("removes the alert box when the close button is clicked", () => {
16
+ const closeButton = document.querySelector("[data-close]");
17
+
18
+ expect(document.querySelector("[data-alert-box]")).not.toBeNull();
19
+
20
+ closeButton.click();
21
+
22
+ expect(document.querySelector("[data-alert-box]")).toBeNull();
23
+ });
24
+
25
+ it("does nothing when clicking outside of the close button", () => {
26
+ const message = document.querySelector(".flash__message");
27
+
28
+ message.click();
29
+
30
+ expect(document.querySelector("[data-alert-box]")).not.toBeNull();
31
+ });
32
+ });
@@ -1,3 +1,4 @@
1
+ /* eslint max-lines: ["error", 350] */
1
2
  /* global jest */
2
3
 
3
4
  import AccordionController from "src/decidim/controllers/accordion/controller";
@@ -99,6 +100,69 @@ describe("AccordionController", () => {
99
100
  });
100
101
  });
101
102
 
103
+ describe("_setupInertPanels", () => {
104
+ const nextTick = () => new Promise((resolve) => setTimeout(resolve, 0));
105
+
106
+ beforeEach(() => {
107
+ panel1.setAttribute("inert", "");
108
+ panel1.setAttribute("aria-hidden", "true");
109
+ panel1.setAttribute("tabindex", "-1");
110
+ panel2.setAttribute("aria-hidden", "true");
111
+ });
112
+
113
+ it("only manages panels with the inert attribute", async () => {
114
+ controller._setupInertPanels();
115
+ expect(controller._panelObservers.length).toBe(1);
116
+
117
+ panel2.setAttribute("aria-hidden", "false");
118
+ await nextTick();
119
+ expect(panel2.hasAttribute("inert")).toBe(false);
120
+ expect("inertManaged" in panel2.dataset).toBe(false);
121
+ });
122
+
123
+ it("removes inert and moves focus to the panel when it gets expanded", async () => {
124
+ controller._setupInertPanels();
125
+
126
+ panel1.setAttribute("aria-hidden", "false");
127
+ await nextTick();
128
+ expect(panel1.hasAttribute("inert")).toBe(false);
129
+ expect(document.activeElement).toBe(panel1);
130
+ });
131
+
132
+ it("restores inert when the panel gets collapsed again", async () => {
133
+ controller._setupInertPanels();
134
+
135
+ panel1.setAttribute("aria-hidden", "false");
136
+ await nextTick();
137
+ panel1.setAttribute("aria-hidden", "true");
138
+ await nextTick();
139
+ expect(panel1.hasAttribute("inert")).toBe(true);
140
+ });
141
+
142
+ it("keeps managing an expanded panel across teardown and setup", async () => {
143
+ controller._setupInertPanels();
144
+
145
+ panel1.setAttribute("aria-hidden", "false");
146
+ await nextTick();
147
+ controller._teardownInertPanels();
148
+
149
+ panel1.setAttribute("aria-hidden", "true");
150
+ controller._setupInertPanels();
151
+ expect(controller._panelObservers.length).toBe(1);
152
+ expect(panel1.hasAttribute("inert")).toBe(true);
153
+ });
154
+
155
+ it("stops syncing after teardown", async () => {
156
+ controller._setupInertPanels();
157
+ controller._teardownInertPanels();
158
+ expect(controller._panelObservers.length).toBe(0);
159
+
160
+ panel1.setAttribute("aria-hidden", "false");
161
+ await nextTick();
162
+ expect(panel1.hasAttribute("inert")).toBe(true);
163
+ });
164
+ });
165
+
102
166
  describe("_applyBreakpointOpenAttributes", () => {
103
167
  beforeEach(() => {
104
168
  controller.initialize();
@@ -31,6 +31,8 @@ export default class extends Controller {
31
31
 
32
32
  this.fixPanelRole();
33
33
 
34
+ this._setupInertPanels();
35
+
34
36
  this.expandIfNeeded();
35
37
 
36
38
  this.boundReconnect = this.reconnect.bind(this);
@@ -52,6 +54,8 @@ export default class extends Controller {
52
54
  this.toggleButton.removeEventListener("click", this.boundExpand);
53
55
  }
54
56
 
57
+ this._teardownInertPanels();
58
+
55
59
  this._stopListeningForBreakpointChanges();
56
60
  }
57
61
 
@@ -166,6 +170,45 @@ export default class extends Controller {
166
170
  this.previouslyExpanded = this.toggleButton.getAttribute("aria-expanded");
167
171
  }
168
172
 
173
+ /**
174
+ * Syncs the `inert` attribute of partially visible panels (those rendered
175
+ * inert) with their `aria-hidden` state, moving focus to the panel on expand.
176
+ *
177
+ * @returns {void}
178
+ */
179
+ _setupInertPanels() {
180
+ this._panelObservers = [];
181
+
182
+ this.element.querySelectorAll("[data-controls]").forEach((trigger) => {
183
+ const panel = document.getElementById(trigger.dataset.controls);
184
+ if (!panel || (!panel.hasAttribute("inert") && !("inertManaged" in panel.dataset))) {
185
+ return;
186
+ }
187
+
188
+ panel.dataset.inertManaged = "";
189
+ panel.toggleAttribute("inert", panel.getAttribute("aria-hidden") === "true");
190
+
191
+ const observer = new MutationObserver(() => {
192
+ if (panel.getAttribute("aria-hidden") === "true") {
193
+ panel.setAttribute("inert", "");
194
+ } else {
195
+ panel.removeAttribute("inert");
196
+ panel.focus();
197
+ }
198
+ });
199
+ observer.observe(panel, { attributes: true, attributeFilter: ["aria-hidden"] });
200
+
201
+ this._panelObservers.push(observer);
202
+ });
203
+ }
204
+
205
+ _teardownInertPanels() {
206
+ if (this._panelObservers) {
207
+ this._panelObservers.forEach((observer) => observer.disconnect());
208
+ this._panelObservers = [];
209
+ }
210
+ }
211
+
169
212
  fixPanelRole() {
170
213
  const panelRole = this.element.dataset.panelRole;
171
214
  if (!panelRole) {