decidim-core 0.32.1 → 0.32.2

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 (149) 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/member/show.erb +1 -1
  5. data/app/cells/decidim/nav_links/show.erb +3 -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 +12 -10
  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 +19 -1
  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 +3 -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/decidim_form_helper.rb +1 -1
  52. data/app/helpers/decidim/sanitize_helper.rb +11 -1
  53. data/app/jobs/decidim/application_job.rb +4 -0
  54. data/app/models/decidim/authorization.rb +24 -0
  55. data/app/models/decidim/oauth_application.rb +1 -0
  56. data/app/models/decidim/user.rb +1 -0
  57. data/app/models/decidim/user_base_entity.rb +1 -1
  58. data/app/packs/src/decidim/callout.js +10 -0
  59. data/app/packs/src/decidim/callout.test.js +32 -0
  60. data/app/packs/src/decidim/controllers/accordion/accordion.test.js +64 -0
  61. data/app/packs/src/decidim/controllers/accordion/controller.js +43 -0
  62. data/app/packs/src/decidim/controllers/autofocus/controller.js +30 -0
  63. data/app/packs/src/decidim/controllers/language_change/controller.js +21 -8
  64. data/app/packs/src/decidim/controllers/language_change/language_change.test.js +0 -1
  65. data/app/packs/src/decidim/controllers/mention/controller.js +32 -3
  66. data/app/packs/src/decidim/controllers/mention/input_mentions.test.js +8 -5
  67. data/app/packs/src/decidim/controllers/multiple_mentions/input_multiple_mentions.test.js +1 -1
  68. data/app/packs/src/decidim/datepicker/datepicker_functions.js +41 -25
  69. data/app/packs/src/decidim/datepicker/generate_datepicker.js +4 -4
  70. data/app/packs/src/decidim/datepicker/test/datepicker_functions_adjust_picker_position.test.js +64 -0
  71. data/app/packs/src/decidim/datepicker/test/time.test.js +39 -24
  72. data/app/packs/src/decidim/editor/common/suggestion.js +32 -22
  73. data/app/packs/src/decidim/editor/extensions/mention/index.js +14 -3
  74. data/app/packs/src/decidim/editor/index.js +4 -0
  75. data/app/packs/src/decidim/editor/test/extensions/mention.test.js +32 -9
  76. data/app/packs/src/decidim/refactor/implementation/autofocus.js +22 -0
  77. data/app/packs/src/decidim/refactor/implementation/autofocus.test.js +171 -0
  78. data/app/packs/src/decidim/utilities/dom.js +2 -2
  79. data/app/packs/stylesheets/decidim/_content_blocks.scss +4 -10
  80. data/app/packs/stylesheets/decidim/_conversations.scss +0 -8
  81. data/app/packs/stylesheets/decidim/_modal.scss +16 -8
  82. data/app/packs/stylesheets/decidim/_modal_share.scss +0 -22
  83. data/app/packs/stylesheets/decidim/_participatory_spaces.scss +11 -1
  84. data/app/packs/stylesheets/decidim/_rich_text.scss +12 -9
  85. data/app/packs/stylesheets/decidim/_typography.scss +5 -2
  86. data/app/packs/stylesheets/decidim/application.scss +1 -0
  87. data/app/packs/stylesheets/decidim/legacy/email.scss +0 -5
  88. data/app/permissions/decidim/permissions.rb +20 -0
  89. data/app/presenters/decidim/admin_log/component_presenter.rb +1 -1
  90. data/app/presenters/decidim/log/base_presenter.rb +10 -1
  91. data/app/presenters/decidim/log/user_presenter.rb +2 -2
  92. data/app/presenters/decidim/menu_item_presenter.rb +1 -5
  93. data/app/presenters/decidim/resource_presenter.rb +11 -0
  94. data/app/resolvers/decidim/meta_image_url_resolver.rb +7 -1
  95. data/app/serializers/decidim/exporters/open_data_blocked_user_serializer.rb +11 -2
  96. data/app/serializers/decidim/exporters/open_data_moderation_serializer.rb +25 -1
  97. data/app/services/decidim/svg_content_validator.rb +554 -0
  98. data/app/uploaders/decidim/application_uploader.rb +2 -2
  99. data/app/validators/uploader_image_content_validator.rb +276 -0
  100. data/app/views/decidim/application/_radio_accordion.html.erb +1 -5
  101. data/app/views/decidim/messaging/conversations/_add_conversation_users.html.erb +1 -1
  102. data/app/views/decidim/messaging/conversations/create.js.erb +1 -3
  103. data/app/views/decidim/shared/_login_modal.html.erb +12 -14
  104. data/app/views/layouts/decidim/_mailer_logo.html.erb +1 -1
  105. data/app/views/layouts/decidim/header/_menu_breadcrumb_desktop.html.erb +3 -2
  106. data/config/initializers/rack_attack.rb +10 -0
  107. data/config/locales/bg.yml +0 -1
  108. data/config/locales/ca-IT.yml +3 -1
  109. data/config/locales/ca.yml +10 -6
  110. data/config/locales/cs.yml +15 -1
  111. data/config/locales/de.yml +99 -3
  112. data/config/locales/en.yml +5 -1
  113. data/config/locales/es-MX.yml +5 -1
  114. data/config/locales/es-PY.yml +5 -1
  115. data/config/locales/es.yml +9 -5
  116. data/config/locales/eu.yml +161 -160
  117. data/config/locales/fi-plain.yml +3 -1
  118. data/config/locales/fi.yml +3 -1
  119. data/config/locales/fr-CA.yml +5 -1
  120. data/config/locales/fr.yml +5 -1
  121. data/config/locales/gl.yml +1485 -33
  122. data/config/locales/hu.yml +0 -1
  123. data/config/locales/ja.yml +3 -1
  124. data/config/locales/pl.yml +0 -1
  125. data/config/locales/pt-BR.yml +0 -1
  126. data/config/locales/ro-RO.yml +0 -1
  127. data/config/locales/sk.yml +2 -1
  128. data/config/locales/sv.yml +68 -12
  129. data/config/locales/tr-TR.yml +0 -2
  130. data/config/routes.rb +11 -2
  131. data/db/migrate/20260909000000_add_failed_attempts_and_locked_at_to_decidim_authorizations.rb +8 -0
  132. data/decidim-core.gemspec +1 -1
  133. data/lib/decidim/api/functions/user_entity_list.rb +1 -0
  134. data/lib/decidim/api/types/user_moderation_type.rb +2 -2
  135. data/lib/decidim/content_parsers/user_parser.rb +1 -1
  136. data/lib/decidim/content_parsers.rb +0 -1
  137. data/lib/decidim/content_renderers/mention_resource_renderer.rb +18 -4
  138. data/lib/decidim/content_renderers/user_renderer.rb +15 -4
  139. data/lib/decidim/core/test/shared_examples/participatory_space_members_page_examples.rb +77 -0
  140. data/lib/decidim/core/test/shared_examples/participatory_space_members_shared_examples.rb +58 -0
  141. data/lib/decidim/core/test/shared_examples/versions_controller_examples.rb +23 -1
  142. data/lib/decidim/core/version.rb +1 -1
  143. data/lib/decidim/core.rb +9 -0
  144. data/lib/decidim/form_builder.rb +9 -1
  145. data/lib/decidim/has_upload_validations.rb +1 -0
  146. data/lib/decidim/legacy_form_builder.rb +4 -0
  147. metadata +12 -7
  148. data/lib/decidim/content_parsers/inline_images_parser.rb +0 -68
  149. data/lib/tasks/upgrade/decidim_active_storage_migration_tasks.rake +0 -79
@@ -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
 
@@ -15,30 +15,41 @@ module Decidim
15
15
  before_action :ensure_profile_holder
16
16
  before_action :ensure_profile_holder_is_a_user, only: :following
17
17
  before_action :ensure_user_not_blocked
18
+ before_action :ensure_user_not_deleted
18
19
 
19
20
  def show
20
- redirect_to profile_activity_path(nickname: params[:nickname].downcase)
21
+ enforce_permission_to :read, :profile
22
+
23
+ redirect_to profile_activity_path(nickname: params.expect(:nickname).downcase)
21
24
  end
22
25
 
23
26
  def following
27
+ enforce_permission_to :read, :profile
28
+
24
29
  @content_cell = "decidim/following"
25
30
  @title_key = "following"
26
31
  render :show
27
32
  end
28
33
 
29
34
  def followers
35
+ enforce_permission_to :read, :profile
36
+
30
37
  @content_cell = "decidim/followers"
31
38
  @title_key = "followers"
32
39
  render :show
33
40
  end
34
41
 
35
42
  def badges
43
+ enforce_permission_to :read, :profile
44
+
36
45
  @content_cell = "decidim/badges"
37
46
  @title_key = "badges"
38
47
  render :show
39
48
  end
40
49
 
41
50
  def activity
51
+ enforce_permission_to :read, :profile
52
+
42
53
  @content_cell = "decidim/user_activity"
43
54
  @title_key = "activity"
44
55
  render :show
@@ -46,6 +57,13 @@ module Decidim
46
57
 
47
58
  private
48
59
 
60
+ # Deleted users should not normally have a nickname, so in normal situation
61
+ # this should not be possible, but in some edge cases there may be accounts
62
+ # left behind with the username field still available.
63
+ def ensure_user_not_deleted
64
+ raise ActionController::RoutingError, "Deleted User" if profile_holder&.deleted?
65
+ end
66
+
49
67
  def ensure_user_not_blocked
50
68
  raise ActionController::RoutingError, "Blocked User" if profile_holder&.blocked? && !current_user&.admin?
51
69
  end
@@ -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,8 +12,11 @@ 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?
19
+ raise ActionController::RoutingError, "Deleted User" if user.deleted?
17
20
  end
18
21
 
19
22
  private
@@ -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
@@ -194,7 +194,7 @@ module Decidim
194
194
  options[:data] ||= {}
195
195
  options[:data].update(live_validate: true, validate_on_blur: true)
196
196
  options[:data].update(controller: "") unless options[:data].has_key?(:controller)
197
- options[:data][:controller] += " form-validator"
197
+ options[:data][:controller] += " form-validator autofocus"
198
198
  options[:data][:controller].strip!
199
199
 
200
200
  options[:html] ||= {}
@@ -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) {
@@ -0,0 +1,30 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import autofocus from "src/decidim/refactor/implementation/autofocus"
3
+
4
+ export default class extends Controller {
5
+ connect() {
6
+ this.handleTabsChange = this.handleTabsChange.bind(this)
7
+
8
+ // Foundation tabs (used for translated fields when locales <= 4)
9
+ this.tabs = Array.from(this.element.querySelectorAll("ul[data-tabs]"))
10
+ if (typeof $ === "function" && this.tabs.length > 0) {
11
+ this.tabs.forEach((tabs) => $(tabs).on("change.zf.tabs", this.handleTabsChange))
12
+ }
13
+
14
+ // Language selector dropdown (used for translated fields when locales > 4)
15
+ if (this.element.querySelector("select.language-change")) {
16
+ autofocus(this.element)
17
+ }
18
+ }
19
+
20
+ disconnect() {
21
+ if (typeof $ === "function" && this.tabs && this.tabs.length > 0) {
22
+ this.tabs.forEach((tabs) => $(tabs).off("change.zf.tabs", this.handleTabsChange))
23
+ }
24
+ }
25
+
26
+ handleTabsChange(event) {
27
+ const activePane = $(event.target).parent().next(".tabs-content").find(".tabs-panel.is-active")[0]
28
+ autofocus(activePane)
29
+ }
30
+ }