diff --git a/.gitignore b/.gitignore index c3262449f0..61d568b580 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ /imports .rspec-failures +Capfile +config/deploy* \ No newline at end of file diff --git a/.rubocop.yml b/.rubocop.yml index 66eabf9d2d..dca2cdbcb4 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -89,7 +89,7 @@ Style/Alias: - prefer_alias_method # Align the elements of a hash literal if they span more than one line. -Layout/AlignHash: +Layout/HashAlignment: # Alignment of entries using hash rocket as separator. Valid values are: # # key - left alignment of keys @@ -152,7 +152,7 @@ Layout/AlignHash: - ignore_implicit - ignore_explicit -Layout/AlignParameters: +Layout/ParameterAlignment: # Alignment of parameters in multi-line method calls. # # The `with_first_parameter` style aligns the following lines along the same @@ -263,20 +263,6 @@ Style/BlockDelimiters: - proc - it -Style/BracesAroundHashParameters: - EnforcedStyle: no_braces - SupportedStyles: - # The `braces` style enforces braces around all method parameters that are - # hashes. - - braces - # The `no_braces` style checks that the last parameter doesn't have braces - # around it. - - no_braces - # The `context_dependent` style checks that the last parameter doesn't have - # braces around it, but requires braces if the second to last parameter is - # also a hash literal. - - context_dependent - # Indentation of `when`. Layout/CaseIndentation: EnforcedStyle: case @@ -469,7 +455,7 @@ Naming/FileName: # files with a shebang in the first line). IgnoreExecutableScripts: true -Layout/IndentFirstArgument: +Layout/FirstArgumentIndentation: EnforcedStyle: special_for_inner_method_call_in_parentheses SupportedStyles: # The first parameter should always be indented one step more than the @@ -557,7 +543,7 @@ Layout/IndentationWidth: Width: 2 # Checks the indentation of the first element in an array literal. -Layout/IndentFirstArrayElement: +Layout/FirstArrayElementIndentation: # The value `special_inside_parentheses` means that array literals with # brackets that have their opening bracket on the same line as a surrounding # opening round parenthesis, shall have their first element indented relative @@ -579,13 +565,13 @@ Layout/IndentFirstArrayElement: IndentationWidth: ~ # Checks the indentation of assignment RHS, when on a different line from LHS -Layout/IndentAssignment: +Layout/AssignmentIndentation: # By default, the indentation width from Style/IndentationWidth is used # But it can be overridden by setting this parameter IndentationWidth: ~ # Checks the indentation of the first key in a hash literal. -Layout/IndentFirstHashElement: +Layout/FirstHashElementIndentation: # The value `special_inside_parentheses` means that hash literals with braces # that have their opening brace on the same line as a surrounding opening # round parenthesis, shall have their first key indented relative to the @@ -792,12 +778,12 @@ Naming/PredicateName: - has_ - have_ # Predicate name prefixes that should be removed. - NamePrefixBlacklist: + ForbiddenPrefixes: - is_ - have_ # Predicate names which, despite having a blacklisted prefix, or no ?, # should still be accepted - NameWhitelist: + AllowedMethods: - is_a? # Exclude Rspec specs because there is a strong convetion to write spec # helpers in the form of `have_something` or `be_something`. @@ -978,7 +964,7 @@ Style/TernaryParentheses: - require_no_parentheses AllowSafeAssignment: true -Layout/TrailingBlankLines: +Layout/TrailingEmptyLines: EnforcedStyle: final_newline SupportedStyles: - final_newline @@ -1028,7 +1014,7 @@ Style/TrivialAccessors: # Commonly used in DSLs AllowDSLWriters: false IgnoreClassMethods: false - Whitelist: + AllowedMethods: - to_ary - to_a - to_c diff --git a/Gemfile b/Gemfile index babe32ea39..7a25ff3717 100644 --- a/Gemfile +++ b/Gemfile @@ -1,15 +1,19 @@ source "https://rubygems.org" -DECIDIM_VERSION = { git: "https://github.com/decidim/decidim", branch: "release/0.24-stable" } +DECIDIM_MAIN_BRANCH = "feature/bcn-budget-v0.24" + +DECIDIM_VERSION = { git: "https://github.com/AjuntamentdeBarcelona/decidim", branch: DECIDIM_MAIN_BRANCH }.freeze ruby '2.7.2' gem "decidim", DECIDIM_VERSION +gem "decidim-census_sms", path: "decidim-census_sms" gem "decidim-dataviz", path: "decidim-dataviz" gem "decidim-initiatives", DECIDIM_VERSION gem "decidim-sortitions", DECIDIM_VERSION gem "decidim-stats", path: "decidim-stats" gem "decidim-valid_auth", path: "decidim-valid_auth" +gem "decidim-ephemeral_participation", path: "decidim-ephemeral_participation" gem "decidim-navigation_maps", "~> 1.2.0" # Change term_customizer dependency to ruby-gems' when term-customizer is compatible with DECIDIM_VERSION @@ -49,6 +53,8 @@ group :development do end group :production do + # can be removed after + gem "letter_opener_web" gem "sidekiq" gem "rails_12factor" gem "fog-aws" diff --git a/Gemfile.lock b/Gemfile.lock index 7fbb8509da..1af85f82d6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,15 +1,7 @@ GIT - remote: https://github.com/CodiTramuntana/decidim-module-term_customizer - revision: c4ed0ffa87bd9977c6b470aa815d5dc2ed9f88a5 - specs: - decidim-term_customizer (0.18.0) - decidim-admin (>= 0.18.0) - decidim-core (>= 0.18.0) - -GIT - remote: https://github.com/decidim/decidim - revision: 2f1e3c8ad256dfc2616530a5049107e6335eceea - branch: release/0.24-stable + remote: https://github.com/AjuntamentdeBarcelona/decidim + revision: e471864fcb4011b096ed31ea312814d6299b9f3d + branch: feature/bcn-budget-v0.24 specs: decidim (0.24.2) decidim-accountability (= 0.24.2) @@ -225,12 +217,32 @@ GIT decidim-verifications (0.24.2) decidim-core (= 0.24.2) +GIT + remote: https://github.com/CodiTramuntana/decidim-module-term_customizer + revision: c4ed0ffa87bd9977c6b470aa815d5dc2ed9f88a5 + specs: + decidim-term_customizer (0.18.0) + decidim-admin (>= 0.18.0) + decidim-core (>= 0.18.0) + +PATH + remote: decidim-census_sms + specs: + decidim-census_sms (0.0.1) + decidim-core + PATH remote: decidim-dataviz specs: decidim-dataviz (0.0.1) decidim-core +PATH + remote: decidim-ephemeral_participation + specs: + decidim-ephemeral_participation (0.0.1) + decidim-verifications + PATH remote: decidim-stats specs: @@ -352,7 +364,7 @@ GEM actionpack (>= 3.0) cells (>= 4.1.6, < 5.0.0) charlock_holmes (0.7.7) - chef-utils (17.0.242) + chef-utils (17.1.35) concurrent-ruby childprocess (3.0.0) coercible (1.0.0) @@ -410,7 +422,7 @@ GEM doc2text (0.4.3) nokogiri (~> 1.11.1) rubyzip (~> 2.3.0) - docile (1.3.5) + docile (1.4.0) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) doorkeeper (5.5.1) @@ -503,7 +515,7 @@ GEM graphiql-rails (1.4.11) railties sprockets-rails - graphql (1.12.9) + graphql (1.12.10) hashdiff (1.0.1) hashie (4.1.0) highline (2.0.3) @@ -653,7 +665,7 @@ GEM activerecord (>= 5.2) activesupport (>= 5.2) polyglot (0.3.5) - premailer (1.14.3) + premailer (1.15.0) addressable css_parser (>= 1.6.0) htmlentities (>= 4.0.0) @@ -839,7 +851,7 @@ GEM smart_properties (1.15.0) social-share-button (1.2.4) coffee-rails - spreadsheet (1.2.8) + spreadsheet (1.2.9) ruby-ole spring (2.1.1) spring-watcher-listen (2.0.1) @@ -891,7 +903,7 @@ GEM virtus (~> 1.0) warden (1.2.9) rack (>= 2.0.9) - webmock (3.12.2) + webmock (3.13.0) addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -917,8 +929,10 @@ DEPENDENCIES dalli database_cleaner decidim! + decidim-census_sms! decidim-dataviz! decidim-dev! + decidim-ephemeral_participation! decidim-initiatives! decidim-navigation_maps (~> 1.2.0) decidim-sortitions! diff --git a/app/assets/stylesheets/_barcelona.scss b/app/assets/stylesheets/_barcelona.scss index 831cb7d6be..5ab323f409 100644 --- a/app/assets/stylesheets/_barcelona.scss +++ b/app/assets/stylesheets/_barcelona.scss @@ -7,3 +7,4 @@ @import "theme-barcelona/hero-custom"; @import "theme-barcelona/event-days"; @import "theme-barcelona/special-process"; +@import "theme-barcelona/budgets"; diff --git a/app/assets/stylesheets/theme-barcelona/_budgets.scss b/app/assets/stylesheets/theme-barcelona/_budgets.scss new file mode 100644 index 0000000000..25b8f8c9c2 --- /dev/null +++ b/app/assets/stylesheets/theme-barcelona/_budgets.scss @@ -0,0 +1,11 @@ +.budget-progress { + .progress-meter { + &:not(&--minimum) { + background-color: $red-light; + } + } +} + +#project .card.extra .button_to { + display: none; +} diff --git a/app/services/census_authorization_handler.rb b/app/services/census_authorization_handler.rb index f71c0b5344..c8e938c9c6 100644 --- a/app/services/census_authorization_handler.rb +++ b/app/services/census_authorization_handler.rb @@ -56,7 +56,7 @@ def census_document_types def unique_id Digest::MD5.hexdigest( - "#{document_number&.upcase}-#{Rails.application.secrets.secret_key_base}" + "#{sanitized_document_number}-#{Rails.application.secrets.secret_key_base}" ) end @@ -83,6 +83,10 @@ def document_type_valid errors.add(:document_number, I18n.t("census_authorization_handler.invalid_document")) unless response.xpath("//codiRetorn").text == "01" end + def sanitized_document_number + document_number&.gsub(/[^A-Za-z0-9]/, "")&.upcase + end + def response return nil if document_number.blank? || document_type.blank? || @@ -109,7 +113,7 @@ def request_body PAM #{sanitized_document_type} - #{sanitize document_number&.upcase} + #{sanitized_document_number} #{sanitize postal_code} #{sanitized_date_of_birth} diff --git a/config/initializers/decidim.rb b/config/initializers/decidim.rb index 5e2613c017..c3730ec0df 100644 --- a/config/initializers/decidim.rb +++ b/config/initializers/decidim.rb @@ -23,6 +23,22 @@ if Rails.application.secrets.sms.values.all?(&:present?) config.sms_gateway_service = "SmsGateway" + + Decidim::Verifications.register_workflow(:census_sms_authorization_handler) do |auth| + auth.engine = Decidim::CensusSms::Verification::Engine + auth.action_authorizer = "Decidim::CensusSms::Verification::ActionAuthorizer" + auth.renewable = true + auth.time_between_renewals = 1.day + auth.ephemerable = true + + auth.options do |options| + parent_scope = Decidim::Scope.where("name->>'ca' = 'Ciutat'").first + + Decidim::Scope.where(parent: parent_scope).pluck(:code).each do |code| + options.attribute :"scope_code_#{code}", type: :boolean, required: false + end + end + end end config.timestamp_service = "TimestampService" @@ -38,6 +54,7 @@ auth.renewable = true auth.time_between_renewals = 1.day auth.metadata_cell = "census_authorization_metadata" + auth.ephemerable = true end Decidim::Verifications.register_workflow(:census16_authorization_handler) do |auth| @@ -45,4 +62,5 @@ auth.renewable = true auth.time_between_renewals = 1.day auth.metadata_cell = "census16_authorization_metadata" + auth.ephemerable = true end diff --git a/config/initializers/decidim_budgets.rb b/config/initializers/decidim_budgets.rb index f01984a31d..f2afcdfdda 100644 --- a/config/initializers/decidim_budgets.rb +++ b/config/initializers/decidim_budgets.rb @@ -1,4 +1,6 @@ # frozen_string_literal: true require "budgets_workflow_pam2020" +require "budgets_workflow_pam2021" Decidim::Budgets.workflows[:pam2020] = BudgetsWorkflowPam2020 +Decidim::Budgets.workflows[:pam2021] = BudgetsWorkflowPam2021 diff --git a/config/locales/ca.yml b/config/locales/ca.yml index f661cfd228..eac57d8ed9 100644 --- a/config/locales/ca.yml +++ b/config/locales/ca.yml @@ -141,6 +141,9 @@ ca: first_login: actions: census_authorization_handler: Verifica't amb el padró + census16_authorization_handler: Verifica't amb el padró (16+) + census_sms_authorization_handler: Verifica't amb el padró i el teu mòbil + valid_auth: Valid auth initiatives: initiatives: supports: @@ -154,4 +157,4 @@ ca: home: extended: debates_explanation: Espais per informar-te i decidir sobre les propostes - de cada procés. + de cada procés. \ No newline at end of file diff --git a/config/locales/decidim_budgets_workflows_ca.yml b/config/locales/decidim_budgets_workflows_ca.yml new file mode 100644 index 0000000000..45ef09a819 --- /dev/null +++ b/config/locales/decidim_budgets_workflows_ca.yml @@ -0,0 +1,11 @@ +ca: + decidim: + components: + budgets: + settings: + global: + ephemerous_census_data_verification: Ephemerous Census Data Verification + workflow_choices: + pam2020: "PAM 2020: allows to Vote in the participant's district and in another of free choice." + pam2021: "PAM 2021: allows to Vote in the participant's district and in another of free choice." + diff --git a/config/locales/decidim_budgets_workflows_en.yml b/config/locales/decidim_budgets_workflows_en.yml index 465fa2bdf7..5d18909d7d 100644 --- a/config/locales/decidim_budgets_workflows_en.yml +++ b/config/locales/decidim_budgets_workflows_en.yml @@ -4,5 +4,7 @@ en: budgets: settings: global: + ephemerous_census_data_verification: Ephemerous Census Data Verification workflow_choices: pam2020: "PAM 2020: allows to Vote in the participant's district and in another of free choice." + pam2021: "PAM 2021: allows to Vote in the participant's district and in another of free choice." diff --git a/config/locales/decidim_budgets_workflows_es.yml b/config/locales/decidim_budgets_workflows_es.yml new file mode 100644 index 0000000000..349f22458f --- /dev/null +++ b/config/locales/decidim_budgets_workflows_es.yml @@ -0,0 +1,11 @@ +es: + decidim: + components: + budgets: + settings: + global: + ephemerous_census_data_verification: Ephemerous Census Data Verification + workflow_choices: + pam2020: "PAM 2020: allows to Vote in the participant's district and in another of free choice." + pam2021: "PAM 2021: allows to Vote in the participant's district and in another of free choice." + diff --git a/config/routes.rb b/config/routes.rb index 0cc29c5da3..6d31ba014f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -55,4 +55,6 @@ mount Decidim::Core::Engine => "/" # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html mount LetterOpenerWeb::Engine, at: "/letter_opener" if Rails.env.development? + + mount Decidim::EphemeralParticipation::Engine, at: "/", as: "decidim_ephemeral_participation" end diff --git a/config/secrets.yml b/config/secrets.yml index b878e0c20f..3334e4e6b0 100644 --- a/config/secrets.yml +++ b/config/secrets.yml @@ -60,6 +60,13 @@ test: secret_key_base: 4a6fd36ca5634dbf42f63331b1a236a041976561ec314414d1750f33ef691dd3705ff2828a607d98bee0ba492e11281a33e736c71e959ab04c76679e74ecb564 geocoder: here_api_key: 1234123412341234 + sms: + service_url: http://example.org/sms + proxy_url: http://example.org/proxy + username: username + password: password + service_id: "1234" + sub_service_id: "1234" # Do not keep production secrets in the repository, # instead read values from the environment. diff --git a/db/migrate/20210518204806_update_organizations_available_authorizations.decidim_ephemeral_participation.rb b/db/migrate/20210518204806_update_organizations_available_authorizations.decidim_ephemeral_participation.rb new file mode 100644 index 0000000000..3b0434893c --- /dev/null +++ b/db/migrate/20210518204806_update_organizations_available_authorizations.decidim_ephemeral_participation.rb @@ -0,0 +1,41 @@ +# This migration comes from decidim_ephemeral_participation (originally 20210518192857) +class UpdateOrganizationsAvailableAuthorizations < ActiveRecord::Migration[5.2] + class Organization < ApplicationRecord + self.table_name = :decidim_organizations + end + + def up + workflows = {} + + Organization.find_each do |organization| + workflows[organization.id] = + organization.available_authorizations.to_a.each_with_object({}) do |workflow, hash| + hash[workflow] = { allow_ephemeral_participation: false } + end + end + + remove_column :decidim_organizations, :available_authorizations + add_column :decidim_organizations, :available_authorizations, :jsonb, default: {} + Organization.reset_column_information + + Organization.find_each do |organization| + organization.update!(available_authorizations: workflows[organization.id]) + end + end + + def down + workflows = {} + + Organization.find_each do |organization| + workflows[organization.id] = organization.available_authorizations.keys.presence + end + + remove_column :decidim_organizations, :available_authorizations + add_column :decidim_organizations, :available_authorizations, :string, array: true, default: [] + Organization.reset_column_information + + Organization.find_each do |organization| + organization.update!(available_authorizations: workflows[organization.id]) + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 851b95f9b1..55883734b6 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: 2021_04_22_132738) do +ActiveRecord::Schema.define(version: 2021_05_18_204806) do # These are extensions that must be enabled in order to support this database enable_extension "ltree" @@ -973,7 +973,6 @@ t.string "github_handler" t.string "reference_prefix", null: false t.string "secondary_hosts", default: [], array: true - t.string "available_authorizations", default: [], array: true t.text "header_snippets" t.jsonb "cta_button_text" t.string "cta_button_path" @@ -1008,6 +1007,7 @@ t.integer "comments_max_length", default: 1000 t.jsonb "file_upload_settings" t.string "machine_translation_display_priority", default: "original", null: false + t.jsonb "available_authorizations", default: {} t.index ["host"], name: "index_decidim_organizations_on_host", unique: true t.index ["name"], name: "index_decidim_organizations_on_name", unique: true end diff --git a/decidim-census_sms/README.md b/decidim-census_sms/README.md new file mode 100644 index 0000000000..9fe18965e1 --- /dev/null +++ b/decidim-census_sms/README.md @@ -0,0 +1,13 @@ +# Decidim::CensusSms::Verification +The CensusSms module adds a verification workflow that checks users against the Census database and sends a code to their mobile phone number to confirm their identity. + +## Usage +Activate workflow in the system panel. + +## Contributing +See [Decidim +Barcelona](https://github.com/AjuntamentdeBarcelona/decidim-barcelona). + +## License +See [Decidim +Barcelona](https://github.com/AjuntamentdeBarcelona/decidim-barcelona). diff --git a/decidim-census_sms/Rakefile b/decidim-census_sms/Rakefile new file mode 100644 index 0000000000..447b5c1bf2 --- /dev/null +++ b/decidim-census_sms/Rakefile @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require "decidim/dev/common_rake" diff --git a/decidim-census_sms/app/assets/config/decidim_census_sms_manifest.css b/decidim-census_sms/app/assets/config/decidim_census_sms_manifest.css new file mode 100644 index 0000000000..830b3d1983 --- /dev/null +++ b/decidim-census_sms/app/assets/config/decidim_census_sms_manifest.css @@ -0,0 +1,3 @@ +/* + *= link decidim/census_sms/verification.scss + */ diff --git a/decidim-census_sms/app/assets/stylesheets/decidim/census_sms/verification.scss b/decidim-census_sms/app/assets/stylesheets/decidim/census_sms/verification.scss new file mode 100644 index 0000000000..4463b25a22 --- /dev/null +++ b/decidim-census_sms/app/assets/stylesheets/decidim/census_sms/verification.scss @@ -0,0 +1,24 @@ +.new_authorization { + .field.date { + label { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 5px; + } + + select { + grid-row: 2; + } + + .label-required { + width: 0.5rem; + grid-column: 3; + justify-self: flex-end; + } + + .form-error { + grid-row: 3; + grid-column: span 3; + } + } +} diff --git a/decidim-census_sms/app/commands/decidim/census_sms/verification/reset_code.rb b/decidim-census_sms/app/commands/decidim/census_sms/verification/reset_code.rb new file mode 100644 index 0000000000..d6fdd3e414 --- /dev/null +++ b/decidim-census_sms/app/commands/decidim/census_sms/verification/reset_code.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Decidim + module CensusSms + module Verification + class ResetCode < Rectify::Command + # Public: Initializes the command. + # + # form - A form object with the params. + # authorization - The authorization object to update. + def initialize(form, authorization) + @form = form + @authorization = authorization + end + + # Executes the command + # Broadcasts these events: + # + # - :ok when everything is valid. + # - :invalid if the handler wasn't valid and we couldn't proceed. + # + # Returns nothing. + def call + return broadcast(:ok) if update_authorization + + broadcast(:invalid) + end + + private + + def update_authorization + metadata = @authorization.metadata + metadata[:mobile_phone_number] = @form.mobile_phone_number_hash + + @authorization.update(metadata: metadata, verification_metadata: verification_metadata) + end + + def verification_metadata + { + verification_code: @form.generated_code, + code_sent_at: Time.current + } + end + end + end + end +end diff --git a/decidim-census_sms/app/controllers/decidim/census_sms/verification/authorizations_controller.rb b/decidim-census_sms/app/controllers/decidim/census_sms/verification/authorizations_controller.rb new file mode 100644 index 0000000000..ee1b41aa09 --- /dev/null +++ b/decidim-census_sms/app/controllers/decidim/census_sms/verification/authorizations_controller.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +module Decidim + module CensusSms + module Verification + class AuthorizationsController < Decidim::ApplicationController + include Decidim::Verifications::Renewable + + helper_method :authorization, :tos_path + + def new + enforce_permission_to :create, :authorization, authorization: authorization + + @form = AuthorizationForm.new + end + + def create + enforce_permission_to :create, :authorization, authorization: authorization + + @form = AuthorizationForm.from_params(create_params) + + Decidim::Verifications::PerformAuthorizationStep.call(authorization, @form) do + on(:ok) do + flash[:notice] = t("authorizations.create.success", scope: "decidim.census_sms.verification") + authorization_method = Decidim::Verifications::Adapter.from_element(authorization.name) + redirect_to authorization_method.resume_authorization_path(redirect_url: redirect_url) + end + + on(:invalid) do + flash.now[:alert] = t("authorizations.create.error", scope: "decidim.census_sms.verification") + render :new + end + end + end + + def edit + enforce_permission_to :update, :authorization, authorization: authorization + + @form = Decidim::Verifications::Sms::ConfirmationForm.from_params(params) + end + + def update + enforce_permission_to :update, :authorization, authorization: authorization + + @form = Decidim::Verifications::Sms::ConfirmationForm.from_params(params) + + Decidim::Verifications::ConfirmUserAuthorization.call(authorization, @form, session) do + on(:ok) do + flash[:notice] = t("authorizations.update.success", scope: "decidim.census_sms.verification") + redirect_to decidim_verifications.authorizations_path + end + + on(:invalid) do + flash.now[:alert] = t("authorizations.update.error", scope: "decidim.census_sms.verification") + render :edit + end + end + end + + def reset + enforce_permission_to :update, :authorization, authorization: authorization + + @form = ResetForm.from_params(params) + + return unless request.post? + + ResetCode.call(@form, authorization) do + on(:ok) do + flash[:notice] = t("authorizations.reset.success", scope: "decidim.census_sms.verification") + authorization_method = Decidim::Verifications::Adapter.from_element(authorization.name) + redirect_to authorization_method.resume_authorization_path(redirect_url: redirect_url) + end + + on(:invalid) do + flash.now[:alert] = t("authorizations.reset.error", scope: "decidim.census_sms.verification") + render :reset + end + end + end + + def destroy + enforce_permission_to :destroy, :authorization, authorization: authorization + + authorization.destroy! + flash[:notice] = t("authorizations.destroy.success", scope: "decidim.census_sms.verification") + + redirect_to action: :new + end + + private + + def create_params + params[:authorization].merge(user: current_user, date_of_birth: date_of_birth) + end + + def date_of_birth + year, month, day = params[:authorization].select { |k, _v| k.include?("date_of_birth") }.values.reverse.map(&:to_i) + + Date.new(year, month, day) + end + + def authorization + @authorization ||= Decidim::Authorization.find_or_initialize_by( + user: current_user, + name: "census_sms_authorization_handler" + ) + end + + def tos_path + @terms_and_conditions_page_path ||= decidim.page_path(Decidim::StaticPage.find_by(slug: "terms-and-conditions", organization: current_organization)) + end + end + end + end +end diff --git a/decidim-census_sms/app/forms/decidim/census_sms/verification/authorization_form.rb b/decidim-census_sms/app/forms/decidim/census_sms/verification/authorization_form.rb new file mode 100644 index 0000000000..dfc0d4ba43 --- /dev/null +++ b/decidim-census_sms/app/forms/decidim/census_sms/verification/authorization_form.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +# Checks the authorization against the census for Barcelona. +require "digest/md5" + +# This class performs a check against the official census database in order +# to verify the citizen's residence and sends an SMS to confirm identity. +module Decidim + module CensusSms + module Verification + class AuthorizationForm < CensusAuthorizationHandler + attribute :mobile_phone_number, String + attribute :tos_acceptance, Boolean + + validates :mobile_phone_number, :verification_code, :sms_gateway, presence: true + validates :tos_acceptance, acceptance: true + + # If you need to store any of the defined attributes in the authorization you + # can do it here. + # + # You must return a Hash that will be serialized to the authorization when + # it's created, and available though authorization.metadata + def metadata + super.merge( + tos_accepted_at: Time.now, + mobile_phone_number: mobile_phone_number_hash, + scope_code: scope.code + ) + end + + # The verification metadata to validate in the next step. + def verification_metadata + { + verification_code: verification_code, + code_sent_at: Time.current + } + end + + # When there's a phone number, sanitize it allowing only numbers and +. + def mobile_phone_number + return unless super + + super.gsub(/[^+0-9]/, "") + end + + private + + def sms_gateway + Decidim.sms_gateway_service.to_s.safe_constantize + end + + def verification_code + return unless sms_gateway + return @verification_code if defined?(@verification_code) + + # DEBUG #TODO UNCOMMENT + # return unless sms_gateway.new(mobile_phone_number, generated_code).deliver_code + + @verification_code = generated_code + end + + def generated_code + @generated_code ||= SecureRandom.random_number(1_000_000).to_s + end + + # A mobile phone can only be verified once but it should be private. + def mobile_phone_number_hash + Digest::MD5.hexdigest( + "#{mobile_phone_number}-#{Rails.application.secrets.secret_key_base}" + ) + end + end + end + end +end diff --git a/decidim-census_sms/app/forms/decidim/census_sms/verification/reset_form.rb b/decidim-census_sms/app/forms/decidim/census_sms/verification/reset_form.rb new file mode 100644 index 0000000000..24bd45e40a --- /dev/null +++ b/decidim-census_sms/app/forms/decidim/census_sms/verification/reset_form.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Decidim + module CensusSms + module Verification + # A form object to reset verification code and send it again. + class ResetForm < Form + attribute :mobile_phone_number, String + + validates :mobile_phone_number, :verification_code, :sms_gateway, presence: true + + # When there's a phone number, sanitize it allowing only numbers and +. + def mobile_phone_number + return unless super + + super.gsub(/[^+0-9]/, "") + end + + # A mobile phone can only be verified once but it should be private. + def mobile_phone_number_hash + Digest::MD5.hexdigest( + "#{mobile_phone_number}-#{Rails.application.secrets.secret_key_base}" + ) + end + + def generated_code + @generated_code ||= SecureRandom.random_number(1_000_000).to_s + end + + private + + def verification_code + return unless sms_gateway + return @verification_code if defined?(@verification_code) + + return unless sms_gateway.new(mobile_phone_number, generated_code).deliver_code + + @verification_code = generated_code + end + + def sms_gateway + Decidim.sms_gateway_service.to_s.safe_constantize + end + end + end + end +end diff --git a/decidim-census_sms/app/services/decidim/census_sms/verification/action_authorizer.rb b/decidim-census_sms/app/services/decidim/census_sms/verification/action_authorizer.rb new file mode 100644 index 0000000000..8859a1b0aa --- /dev/null +++ b/decidim-census_sms/app/services/decidim/census_sms/verification/action_authorizer.rb @@ -0,0 +1,35 @@ +module Decidim + module CensusSms + module Verification + class ActionAuthorizer < Decidim::Verifications::DefaultActionAuthorizer + BASE_OPTION_KEY = "scope_code".freeze + + protected + + def missing_fields + authorized_code.present? ? [] : [BASE_OPTION_KEY] + end + + def unmatched_fields + scope_valid? ? [] : [BASE_OPTION_KEY] + end + + private + + def scope_valid? + scope_codes = options.keys.map { |k| k.gsub("#{BASE_OPTION_KEY}_", "") if k.match?(BASE_OPTION_KEY) }.compact + + return unless scope_codes.any? + + authorized_code_key = "#{BASE_OPTION_KEY}_#{authorized_code}" + + authorized_code.present? && options[authorized_code_key] == "1" + end + + def authorized_code + authorization.metadata[BASE_OPTION_KEY] + end + end + end + end +end diff --git a/decidim-census_sms/app/views/decidim/census_sms/verification/authorizations/edit.html.erb b/decidim-census_sms/app/views/decidim/census_sms/verification/authorizations/edit.html.erb new file mode 100644 index 0000000000..5fd88782fa --- /dev/null +++ b/decidim-census_sms/app/views/decidim/census_sms/verification/authorizations/edit.html.erb @@ -0,0 +1,29 @@ +
+
+
+

<%= t(".title") %>

+
+ +
+
+ <%= decidim_form_for(@form, url: authorization_path(redirect_url: redirect_url), method: :put) do |form| %> + <%= form_required_explanation %> + +
+ <%= form.text_field :verification_code %> +
+ +
+ <%= form.submit t(".send"), class: "button expanded", "data-disable-with" => "#{t('.send')}..." %> +
+ <% end %> +
+
+
+
+ <%= t(".instructions") %> + <%= link_to t(".reset"), reset_authorization_path(id: authorization.id) %> +
+
+
+
diff --git a/decidim-census_sms/app/views/decidim/census_sms/verification/authorizations/new.html.erb b/decidim-census_sms/app/views/decidim/census_sms/verification/authorizations/new.html.erb new file mode 100644 index 0000000000..9ad8b079c7 --- /dev/null +++ b/decidim-census_sms/app/views/decidim/census_sms/verification/authorizations/new.html.erb @@ -0,0 +1,58 @@ +
+
+
+

<%= t(".title") %>

+
+ +
+
+ <%= decidim_form_for(@form, url: authorization_path) do |form| %> + <%= form_required_explanation %> + +
+ <%= form.select :document_type, form.object.census_document_types, prompt: true %> +
+ +
+ <%= form.text_field :document_number %> +
+ +
+ <%= form.date_select :date_of_birth, start_year: 1900, end_year: 14.years.ago.year, default: 35.years.ago, prompt: { day: t(".date_select.day"), month: t(".date_select.month"), year: t(".date_select.year") } %> +
+ +
+ <%= form.text_field :postal_code %> +

+ <%== t(".postal_code_help") %> +

+
+ +
+ <% parent_scope = Decidim::Scope.where("name->>'ca' = 'Ciutat'").first %> + <%= form.collection_select :scope_id, current_organization.scopes.where(parent: parent_scope), :id, ->(scope){ translated_attribute(scope.name) }, prompt: t(".scope_prompt") %> +
+ +
+ <%= form.phone_field :mobile_phone_number %> +
+ +
+ <%= form.label :tos_acceptance do %> + <%= form.check_box :tos_acceptance, label: false %> + <%== t("activemodel.attributes.authorization.tos_acceptance_label", tos_path: tos_path) %> + <% end %> +
+ + <%= form.hidden_field :handler_name %> + +
+ <%= form.submit t(".send"), class: "button expanded", "data-disable-with" => "#{t('.send')}..." %> +
+ <% end %> +
+
+
+
+ +<%= stylesheet_link_tag "decidim/census_sms/verification" %> diff --git a/decidim-census_sms/app/views/decidim/census_sms/verification/authorizations/reset.html.erb b/decidim-census_sms/app/views/decidim/census_sms/verification/authorizations/reset.html.erb new file mode 100644 index 0000000000..c638e99df5 --- /dev/null +++ b/decidim-census_sms/app/views/decidim/census_sms/verification/authorizations/reset.html.erb @@ -0,0 +1,23 @@ +
+
+
+

<%= t(".title") %>

+
+ +
+
+ <%= decidim_form_for(@form, url: reset_authorization_path(id: authorization.id), method: :post) do |form| %> + <%= form_required_explanation %> + +
+ <%= form.text_field :mobile_phone_number, label: t("mobile_phone_number", scope: "activemodel.attributes.authorization") %> +
+ +
+ <%= form.submit t(".send"), class: "button expanded", "data-disable-with" => "#{t('.send')}..." %> +
+ <% end %> +
+
+
+
diff --git a/decidim-census_sms/config/i18n-tasks.yml b/decidim-census_sms/config/i18n-tasks.yml new file mode 100644 index 0000000000..7c55c3e957 --- /dev/null +++ b/decidim-census_sms/config/i18n-tasks.yml @@ -0,0 +1,2 @@ +base_locale: ca +locales: [ca,es] \ No newline at end of file diff --git a/decidim-census_sms/config/locales/ca.yml b/decidim-census_sms/config/locales/ca.yml new file mode 100644 index 0000000000..a4cc568cc2 --- /dev/null +++ b/decidim-census_sms/config/locales/ca.yml @@ -0,0 +1,58 @@ +--- +ca: + activemodel: + attributes: + authorization: + date_of_birth: Data de naixement + document_number: Número de document + document_type: Tipus de document + mobile_phone_number: Número de telèfon mòbil + postal_code: Codi postal + scope_id: Districte + tos_acceptance: Accepto els Termes i Condicions + tos_acceptance_label: En registrar-te acceptes els Termes i Condicions + decidim: + authorization_handlers: + census_sms_authorization_handler: + explanation: El padró + SMS #TODO + name: El padró + SMS #TODO + fields: + scope_code_1: Districte 1 #TODO + scope_code_2: Districte 2 #TODO + scope_code_3: Districte 3 #TODO + scope_code_4: Districte 4 #TODO + scope_code_5: Districte 5 #TODO + scope_code_6: Districte 6 #TODO + scope_code_7: Districte 7 #TODO + scope_code_8: Districte 8 #TODO + scope_code_9: Districte 9 #TODO + scope_code_10: Districte 10 #TODO + scope_code: Districte + census_sms: + verification: + authorizations: + create: + error: S'ha produit un error en crear l'autorització + success: Has completat el primer pas per obtenir l'autorització + edit: + instructions: No has rebut el codi de verificació? + reset: Restableix el codi de verificació + send: Verifica't + title: Introdueix el codi que has rebut per SMS + new: + date_select: + day: Dia + month: Mes + year: Any + postal_code_help: No saps quin codi postal correspon a la teva adreça del Padró? Pots consultar-ho clicant aquí. + scope_prompt: Selecciona el teu districte + send: Verifica't + title: Verifica't amb el Padró + reset: + mobile_phone_number: Número de telèfon mòbil + send: Envia'm un nou codi + title: Restableix el codi de verificació + success: T'hem enviat un nou codi de verificació + update: + error: El codi de verificació que has introduït no coincideix amb el nostre. Si us plau, revisa l'SMS que t'hem enviat. + success: Felicitats. T'has verificat correctament. diff --git a/decidim-census_sms/config/locales/es.yml b/decidim-census_sms/config/locales/es.yml new file mode 100644 index 0000000000..5f56cbc36f --- /dev/null +++ b/decidim-census_sms/config/locales/es.yml @@ -0,0 +1,5 @@ +--- +es: + decidim: + census_sms: + verification: \ No newline at end of file diff --git a/decidim-census_sms/decidim-census_sms.gemspec b/decidim-census_sms/decidim-census_sms.gemspec new file mode 100644 index 0000000000..b1a7202833 --- /dev/null +++ b/decidim-census_sms/decidim-census_sms.gemspec @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +$LOAD_PATH.push File.expand_path("lib", __dir__) + +Gem::Specification.new do |s| + s.name = "decidim-census_sms" + s.summary = "A verification workflow for Decidim Barcelona." + s.description = s.summary + s.version = "0.0.1" + s.authors = ["Vera Rojman"] + s.email = ["vera@platoniq.net"] + + s.files = Dir["{app,config,lib}/**/*", "Rakefile", "README.md"] + + s.add_dependency "decidim-core" + + s.add_development_dependency "decidim-dev" +end diff --git a/decidim-census_sms/lib/decidim/census_sms.rb b/decidim-census_sms/lib/decidim/census_sms.rb new file mode 100644 index 0000000000..9710f1a689 --- /dev/null +++ b/decidim-census_sms/lib/decidim/census_sms.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "decidim/census_sms/verification" + +module Decidim + # Base module for this engine. + module CensusSms + end +end diff --git a/decidim-census_sms/lib/decidim/census_sms/verification.rb b/decidim-census_sms/lib/decidim/census_sms/verification.rb new file mode 100644 index 0000000000..4f2f5b3949 --- /dev/null +++ b/decidim-census_sms/lib/decidim/census_sms/verification.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "decidim/census_sms/verification/engine" + +module Decidim + module CensusSms + module Verification + end + end +end diff --git a/decidim-census_sms/lib/decidim/census_sms/verification/engine.rb b/decidim-census_sms/lib/decidim/census_sms/verification/engine.rb new file mode 100644 index 0000000000..27e137ba4f --- /dev/null +++ b/decidim-census_sms/lib/decidim/census_sms/verification/engine.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Decidim + module CensusSms + module Verification + # This is an engine that performs user authorization. + class Engine < ::Rails::Engine + isolate_namespace Decidim::CensusSms::Verification + + paths["db/migrate"] = nil + paths["lib/tasks"] = nil + + routes do + resource :authorizations, only: [:new, :create, :edit, :update, :destroy], as: :authorization do + get :reset, on: :member + post :reset, on: :member + get :renew, on: :collection + end + root to: "authorizations#new" + end + + initializer "decidim_census_sms.assets" do |app| + app.config.assets.precompile += %w(decidim_census_sms_manifest.css + decidim/census_sms/verification.scss) + end + end + end + end +end diff --git a/decidim-ephemeral_participation/README.md b/decidim-ephemeral_participation/README.md new file mode 100644 index 0000000000..785ab21109 --- /dev/null +++ b/decidim-ephemeral_participation/README.md @@ -0,0 +1,44 @@ +# Decidim::EphemeralParticipation + +This module adds a specific integration with local Barcelona services so citizens can participate without registration. + +> NOTE: this module might be provisional if the main changes introduces by it are ported to `decidim-core` +> (which is the intention of the team behind it) + +## Install + +Available_authorizations can now hold some configurable data, this migrations takes car of updating the organization field: + +``` +rails decidim_ephemeral_participation:install:migrations +``` + +In order to allow an Authorization handler to be used for ephemeral participation, it needs to be configured in the initializer: + +```ruby +# config/initializers/decidim.rb + +Decidim::Verifications.register_workflow(:census) do |workflow| + workflow.form = "myAuthorizationHandlerClass" + workflow.renewable = true + workflow.time_between_renewals = 1.day + workflow.metadata_cell = "decidim/verifications/authorization_metadata" + # set the next varialble to true to allows the system admin use this as a method for direct participation + workflow.ephemerable = true +end +``` + +**IMPORANT** +The following assumptions are made: +- The verification workflow is responsible for making users accept the TOS. +- The verification workflow is redirecting to `authorizations_path` or `redirect_url` after creating the authorization. + +## Contributing + +See [Decidim +Barcelona](https://github.com/AjuntamentdeBarcelona/decidim-barcelona). + +## License + +See [Decidim +Barcelona](https://github.com/AjuntamentdeBarcelona/decidim-barcelona). diff --git a/decidim-ephemeral_participation/app/cells/decidim/ephemeral_participation/project_list_item_cell_override.rb b/decidim-ephemeral_participation/app/cells/decidim/ephemeral_participation/project_list_item_cell_override.rb new file mode 100644 index 0000000000..8c58734004 --- /dev/null +++ b/decidim-ephemeral_participation/app/cells/decidim/ephemeral_participation/project_list_item_cell_override.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + module ProjectListItemCellOverride + def vote_button_disabled? + return unless current_user + return if current_user.ephemeral_participant? + + !can_have_order? + end + end + end +end \ No newline at end of file diff --git a/decidim-ephemeral_participation/app/commands/decidim/ephemeral_participation/create_ephemeral_participant.rb b/decidim-ephemeral_participation/app/commands/decidim/ephemeral_participation/create_ephemeral_participant.rb new file mode 100644 index 0000000000..094ba78a63 --- /dev/null +++ b/decidim-ephemeral_participation/app/commands/decidim/ephemeral_participation/create_ephemeral_participant.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + class CreateEphemeralParticipant < Rectify::Command + include ::Devise::Controllers::Helpers + + def initialize(request, current_user) + @request = request + @current_user = current_user + end + + def call + return broadcast(:invalid) unless valid_params? + + sign_in(new_ephemeral_participant) unless @current_user + + broadcast(:ok, authorization_path) + end + + private + + def valid_params? + @request.is_a?(ActionDispatch::Request) && component_id.present? && ephemeral_participation_path.present? + end + + def component_id + @request.params[:component_id] + end + + def ephemeral_participation_path + @request.params[:ephemeral_participation_path] + end + + def new_ephemeral_participant + Decidim::User.new( + organization: component.organization, + managed: true, + tos_agreement: true, + extended_data: { + ephemeral_participation: { + authorization_name: authorization_name, + component_id: component.id, + permissions: component.ephemeral_participation_permissions, + request_path: ephemeral_participation_path + } + } + ).tap do |user| + user.nickname = nicknamize(user) + user.save! + end + end + + # nickname is needed to ensure some links are not broken + def nicknamize(user) + Decidim::UserBaseEntity.nicknamize(user.name, organization: user.organization) + end + + def authorization_path + adapter.root_path(redirect_url: ephemeral_participation_path) + end + + def adapter + @adapter ||= Decidim::Verifications::Adapter.from_element(authorization_name) + end + + def authorization_name + component.organization.ephemeral_participation_authorization + end + + def component + @component ||= Decidim::Component.find(component_id) + end + + # Needed for Devise::Controllers::Helpers#sign_in + def session + @request.session + end + end + end +end diff --git a/decidim-ephemeral_participation/app/commands/decidim/ephemeral_participation/destroy_ephemeral_participant.rb b/decidim-ephemeral_participation/app/commands/decidim/ephemeral_participation/destroy_ephemeral_participant.rb new file mode 100644 index 0000000000..63e06f9cc7 --- /dev/null +++ b/decidim-ephemeral_participation/app/commands/decidim/ephemeral_participation/destroy_ephemeral_participant.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + class DestroyEphemeralParticipant < Rectify::Command + include ::Devise::Controllers::Helpers + + def initialize(request, user) + @request = request + @user = user + end + + def call + return broadcast(:invalid) unless valid_params? + + @user.invalidate_all_sessions! + @user.destroy! if destroy_user? + sign_out(@user) + + broadcast(:ok) + end + + private + + def valid_params? + @request.is_a?(ActionDispatch::Request) && @user.is_a?(Decidim::User) + end + + def destroy_user? + return false if @user.verified_ephemeral_participant? + return false if verification_conflicts.any? + + true + end + + def verification_conflicts + Decidim::Verifications::Conflict.where(current_user: @user) + end + + # Needed for Devise::Controllers::Helpers#sign_out + def session + @request.session + end + end + end +end diff --git a/decidim-ephemeral_participation/app/commands/decidim/ephemeral_participation/transfer_ephemeral_participant.rb b/decidim-ephemeral_participation/app/commands/decidim/ephemeral_participation/transfer_ephemeral_participant.rb new file mode 100644 index 0000000000..66f27b8e77 --- /dev/null +++ b/decidim-ephemeral_participation/app/commands/decidim/ephemeral_participation/transfer_ephemeral_participant.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + class TransferEphemeralParticipant < Rectify::Command + def initialize(verified_user, unverifiable_user, form) + @verified_user = verified_user + @unverifiable_user = unverifiable_user + @form = form + end + + def call + return broadcast(:invalid) unless valid_params? + + update_verified_user + update_unverifiable_user + + broadcast(:ok) + end + + private + + def valid_params? + @verified_user.verified_ephemeral_participant? + end + + + def update_verified_user + @verified_user.session_token = SecureRandom.hex + @verified_user.managed = false + + @verified_user.email = @form.email + + @verified_user.skip_reconfirmation! + @verified_user.save! + @verified_user.send_reset_password_instructions + end + + def update_unverifiable_user + Decidim::DestroyAccount.call( + @unverifiable_user, + Decidim::DeleteAccountForm.from_params(reason: @form.reason), + ) + end + end + end +end diff --git a/decidim-ephemeral_participation/app/commands/decidim/ephemeral_participation/transfer_user_override.rb b/decidim-ephemeral_participation/app/commands/decidim/ephemeral_participation/transfer_user_override.rb new file mode 100644 index 0000000000..ab9080ac57 --- /dev/null +++ b/decidim-ephemeral_participation/app/commands/decidim/ephemeral_participation/transfer_user_override.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + module TransferUserOverride + extend ActiveSupport::Concern + + included do + alias :update_regular_managed_user :update_managed_user + + private + + def update_managed_user + if managed_user.verified_ephemeral_participant? + Decidim::EphemeralParticipation::TransferEphemeralParticipant.call(managed_user, new_user, form) + else + update_regular_managed_user + end + end + end + end + end +end diff --git a/decidim-ephemeral_participation/app/commands/decidim/ephemeral_participation/update_ephemeral_participant.rb b/decidim-ephemeral_participation/app/commands/decidim/ephemeral_participation/update_ephemeral_participant.rb new file mode 100644 index 0000000000..1544fa9487 --- /dev/null +++ b/decidim-ephemeral_participation/app/commands/decidim/ephemeral_participation/update_ephemeral_participant.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + class UpdateEphemeralParticipant < Rectify::Command + include ::Devise::Controllers::Helpers + + def initialize(request, user, form) + @request = request + @user = user + @form = form + end + + def call + return broadcast(:invalid) unless valid_params? + + update_user + bypass_sign_in(@user) + + broadcast(:ok) + end + + private + + def valid_params? + @request.is_a?(ActionDispatch::Request) && @user.is_a?(Decidim::User) && @form.valid? + end + + def update_user + @user.managed = false + @user.accepted_tos_version = @user.organization.tos_version + + @user.name = @form.name + @user.nickname = @form.nickname + @user.email = @form.email + @user.password = @form.password + @user.password_confirmation = @form.password_confirmation + @user.password_confirmation = @form.password_confirmation + + @user.skip_reconfirmation! + @user.save! + @user.send(:after_confirmation) + end + + # Needed for Devise::Controllers::Helpers#bypass_sign_in + def session + @request.session + end + end + end +end diff --git a/decidim-ephemeral_participation/app/controllers/concerns/decidim/ephemeral_participation/ephemeral_participable.rb b/decidim-ephemeral_participation/app/controllers/concerns/decidim/ephemeral_participation/ephemeral_participable.rb new file mode 100644 index 0000000000..960fdd5c2c --- /dev/null +++ b/decidim-ephemeral_participation/app/controllers/concerns/decidim/ephemeral_participation/ephemeral_participable.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + module EphemeralParticipable + extend ActiveSupport::Concern + + included do + + before_action :destroy_ephemeral_participant, if: :ephemeral_participant_session? + before_action :redirect_ephemeral_participant, if: :ephemeral_participant_session? + before_action :inform_ephemeral_participant, if: :ephemeral_participant_session? + + private + + def ephemeral_participant_session? + current_user && current_user.ephemeral_participant? + end + + def destroy_ephemeral_participant + return end_unverifiable_ephemeral_participant_session if end_unverifiable_ephemeral_participant_session? + return end_expired_ephemeral_participant_session if end_expired_ephemeral_participant_session? + end + + def end_unverifiable_ephemeral_participant_session + Decidim::EphemeralParticipation::DestroyEphemeralParticipant.call(request, current_user) do + on(:ok) do + flash[:alert] = I18n.t("unverifiable", scope: "decidim.ephemeral_participation.ephemeral_participants") + + redirect_to(Decidim::Core::Engine.routes.url_helpers.root_path) + end + end + end + + def end_unverifiable_ephemeral_participant_session? + current_user.unverifiable_ephemeral_participant? + end + + def end_expired_ephemeral_participant_session + Decidim::EphemeralParticipation::DestroyEphemeralParticipant.call(request, current_user) do + on(:ok) do + flash[:notice] = I18n.t("destroy", scope: "decidim.ephemeral_participation.ephemeral_participants") + + redirect_to(Decidim::Core::Engine.routes.url_helpers.root_path) + end + end + end + + def end_expired_ephemeral_participant_session? + Decidim::EphemeralParticipation::SessionPresenter.new(current_user, helpers).ephemeral_participant_session_expired? + end + + def redirect_ephemeral_participant + return redirect_to(ephemeral_participation_path) if redirect_to_ephemeral_participation_path? + return redirect_to(edit_ephemeral_participant_path(current_user)) if redirect_to_edit_ephemeral_participant_path? + end + + def ephemeral_participation_path + current_user.ephemeral_participation_data["request_path"] + end + + def redirect_to_ephemeral_participation_path? + Decidim::EphemeralParticipation::RedirectionRecognizer.new(request, current_user).redirect_to_ephemeral_participation_path? + end + + def edit_ephemeral_participant_path(current_user) + Decidim::EphemeralParticipation::Engine.routes.url_helpers.edit_ephemeral_participant_path(current_user) + end + + def redirect_to_edit_ephemeral_participant_path? + Decidim::EphemeralParticipation::RedirectionRecognizer.new(request, current_user).redirect_to_edit_ephemeral_participant_path? + end + + def inform_ephemeral_participant + return (flash.now[:warning] = unverified_ephemeral_participant_message) if inform_unverified_ephemeral_participant? + return (flash.now[:warning] = verified_ephemeral_participant_message) if inform_verified_ephemeral_participant? + end + + def unverified_ephemeral_participant_message + Decidim::EphemeralParticipation::FlashMessagesPresenter.new(current_user, helpers).unverified_ephemeral_participant_message + end + + def inform_unverified_ephemeral_participant? + Decidim::EphemeralParticipation::InformingRecognizer.new(request, current_user).inform_unverified_ephemeral_participant? + end + + def verified_ephemeral_participant_message + Decidim::EphemeralParticipation::FlashMessagesPresenter.new(current_user, helpers).verified_ephemeral_participant_message + end + + def inform_verified_ephemeral_participant? + Decidim::EphemeralParticipation::InformingRecognizer.new(request, current_user).inform_verified_ephemeral_participant? + end + end + end + end +end diff --git a/decidim-ephemeral_participation/app/controllers/concerns/decidim/ephemeral_participation/needs_permission_override.rb b/decidim-ephemeral_participation/app/controllers/concerns/decidim/ephemeral_participation/needs_permission_override.rb new file mode 100644 index 0000000000..1e9499abba --- /dev/null +++ b/decidim-ephemeral_participation/app/controllers/concerns/decidim/ephemeral_participation/needs_permission_override.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + module NeedsPermissionOverride + extend ActiveSupport::Concern + + included do + alias :old_user_has_no_permission :user_has_no_permission + alias :old_permissions_context :permissions_context + + def user_has_no_permission + if request.xhr? + new_user_has_no_permission + else + old_user_has_no_permission + end + end + + def permissions_context + old_permissions_context.merge(request: request) + end + + private + + def new_user_has_no_permission + render(js: unauthorized_error_flash_message_js) + end + + def unauthorized_error_flash_message_js + <<~JAVASCRIPT + $alertBoxParsedHtml = $.parseHTML('#{unauthorized_error_flash_message_html}')[0].outerHTML; + alertBoxNotFound = $('#content').html().indexOf($alertBoxParsedHtml) == -1; + + if (alertBoxNotFound) $('#content').prepend($alertBoxParsedHtml); + + $(window).scrollTop(0); + JAVASCRIPT + end + + def unauthorized_error_flash_message_html + flash.clear + + flash.now[:alert] = unauthorized_message + + helpers.display_flash_messages + end + + def unauthorized_message + if (current_user && current_user.ephemeral_participant?) + unauthorized_ephemeral_participant_message + else + I18n.t("actions.unauthorized", scope: "decidim.core") + end + end + + def unauthorized_ephemeral_participant_message + presenter = Decidim::EphemeralParticipation::FlashMessagesPresenter.new(current_user, helpers) + + if current_user.verified_ephemeral_participant? + presenter.unverified_ephemeral_participant_message + else + presenter.unauthorized_ephemeral_participant_message + end + end + end + end + end +end diff --git a/decidim-ephemeral_participation/app/controllers/decidim/ephemeral_participation/application_controller_override.rb b/decidim-ephemeral_participation/app/controllers/decidim/ephemeral_participation/application_controller_override.rb new file mode 100644 index 0000000000..d70eb6addf --- /dev/null +++ b/decidim-ephemeral_participation/app/controllers/decidim/ephemeral_participation/application_controller_override.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + module ApplicationControllerOverride + extend ActiveSupport::Concern + + included do + include Decidim::EphemeralParticipation::EphemeralParticipable + include Decidim::EphemeralParticipation::NeedsPermissionOverride + end + end + end +end diff --git a/decidim-ephemeral_participation/app/controllers/decidim/ephemeral_participation/conflicts_controller_override.rb b/decidim-ephemeral_participation/app/controllers/decidim/ephemeral_participation/conflicts_controller_override.rb new file mode 100644 index 0000000000..4717e865ec --- /dev/null +++ b/decidim-ephemeral_participation/app/controllers/decidim/ephemeral_participation/conflicts_controller_override.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + module ConflictsControllerOverride + extend ActiveSupport::Concern + + included do + # TEMPORARY OVERRIDE TO RENDER FORM ON ERROR (BUG IN DECIDIM) + # https://github.com/decidim/decidim/blob/00bad01ccfa95473fd2d7b2f2cb1919623295ba3/decidim-admin/app/controllers/decidim/admin/conflicts_controller.rb#L40 + def update + conflict = Decidim::Verifications::Conflict.find(params[:id]) + + @form = form(Decidim::Admin::TransferUserForm).from_params( + current_user: current_user, + conflict: conflict, + reason: params[:transfer_user][:reason], + email: params[:transfer_user][:email] + ) + + Decidim::Admin::TransferUser.call(@form) do + on(:ok) do + flash[:notice] = I18n.t("success", scope: "decidim.admin.conflicts.transfer") + redirect_to conflicts_path + end + + on(:invalid) do + flash.now[:alert] = I18n.t("error", scope: "decidim.admin.conflicts.transfer") + render :edit + end + end + end + end + end + end +end diff --git a/decidim-ephemeral_participation/app/controllers/decidim/ephemeral_participation/ephemeral_participants_controller.rb b/decidim-ephemeral_participation/app/controllers/decidim/ephemeral_participation/ephemeral_participants_controller.rb new file mode 100644 index 0000000000..4300081950 --- /dev/null +++ b/decidim-ephemeral_participation/app/controllers/decidim/ephemeral_participation/ephemeral_participants_controller.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + class EphemeralParticipantsController < Decidim::ApplicationController + include FormFactory + + def create + enforce_permission_to(:create, :ephemeral_participant) + + Decidim::EphemeralParticipation::CreateEphemeralParticipant.call(request, current_user) do + on(:ok) do |authorization_path| + flash[:notice] = I18n.t("create", scope: "decidim.ephemeral_participation.ephemeral_participants") + + redirect_to(authorization_path) + end + + on(:invalid) do + render template: "decidim/errors/not_found", locals: { root_path: decidim_root_path } + end + end + end + + def edit + enforce_permission_to(:update, :ephemeral_participant, current_user: current_user) + + @form = form(EphemeralParticipantForm).from_model(current_user) + + render(layout: "layouts/decidim/ephemeral_participation/user_profile") + end + + def update + enforce_permission_to(:update, :ephemeral_participant, current_user: current_user) + + @form = form(EphemeralParticipantForm).from_params(params) + + Decidim::EphemeralParticipation::UpdateEphemeralParticipant.call(request, current_user, @form) do + on(:ok) do + flash[:notice] = I18n.t("update.success", scope: "decidim.ephemeral_participation.ephemeral_participants") + + redirect_to(decidim.account_path) + end + + on(:invalid) do + flash[:alert] = I18n.t("update.error", scope: "decidim.ephemeral_participation.ephemeral_participants") + + render(action: :edit) + end + end + end + + def destroy + enforce_permission_to(:destroy, :ephemeral_participant, current_user: current_user) + + Decidim::EphemeralParticipation::DestroyEphemeralParticipant.call(request, current_user) do + on(:ok) do + flash[:notice] = I18n.t("destroy", scope: "decidim.ephemeral_participation.ephemeral_participants") + + redirect_to(decidim_root_path) + end + end + end + + private + + def decidim_root_path + Decidim::Core::Engine.routes.url_helpers.root_path + end + end + end +end diff --git a/decidim-ephemeral_participation/app/forms/decidim/ephemeral_participation/component_form_override.rb b/decidim-ephemeral_participation/app/forms/decidim/ephemeral_participation/component_form_override.rb new file mode 100644 index 0000000000..bd44357c6a --- /dev/null +++ b/decidim-ephemeral_participation/app/forms/decidim/ephemeral_participation/component_form_override.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + module ComponentFormOverride + extend ActiveSupport::Concern + + included do + validate :validate_ephemeral_participation_enabled + + private + + def validate_ephemeral_participation_enabled + return unless settings.try(:ephemeral_participation_enabled) == true + return if participatory_space.organization.ephemeral_participation_authorization + + settings.errors.add(:ephemeral_participation_enabled, :missing_ephemeral_participation_authorization) + end + end + end + end +end diff --git a/decidim-ephemeral_participation/app/forms/decidim/ephemeral_participation/ephemeral_participant_form.rb b/decidim-ephemeral_participation/app/forms/decidim/ephemeral_participation/ephemeral_participant_form.rb new file mode 100644 index 0000000000..72eecf0469 --- /dev/null +++ b/decidim-ephemeral_participation/app/forms/decidim/ephemeral_participation/ephemeral_participant_form.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + class EphemeralParticipantForm < Decidim::Form + mimic :ephemeral_participant + + attribute :name + attribute :nickname + attribute :email + attribute :password + attribute :password_confirmation + + validates :name, presence: true + validates :email, presence: true, 'valid_email_2/email': { disposable: true } + validates :nickname, presence: true, format: Decidim::User::REGEXP_NICKNAME + validates :nickname, length: { maximum: Decidim::User.nickname_max_length, allow_blank: true } + validates :password, presence: true, confirmation: true + validates :password, password: { name: :name, email: :email, username: :nickname } + validates :password_confirmation, presence: true + + validate :unique_email + validate :unique_nickname + + alias organization current_organization + + private + + def unique_email + return if duplicates(email: email).none? + + errors.add(:email, :taken) + end + + def unique_nickname + return if duplicates(nickname: nickname).none? + + errors.add(:nickname, :taken) + end + + def duplicates(where_clause) + Decidim::EphemeralParticipation::DuplicatedUsers.new( + organization: current_organization, + excluding: current_user, + where_clause: where_clause, + ).query + end + end + end +end diff --git a/decidim-ephemeral_participation/app/forms/decidim/ephemeral_participation/permissions_form_override.rb b/decidim-ephemeral_participation/app/forms/decidim/ephemeral_participation/permissions_form_override.rb new file mode 100644 index 0000000000..1d813359b0 --- /dev/null +++ b/decidim-ephemeral_participation/app/forms/decidim/ephemeral_participation/permissions_form_override.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + module PermissionsFormOverride + extend ActiveSupport::Concern + + included do + attribute :component_id, String + attribute :resource_id, String + + validate :validate_ephemeral_participation_permissions_settings + + private + + def validate_ephemeral_participation_permissions_settings + return if resource_permissions? + return unless ephemeral_participation_enabled? + + permissions.values.each do |permission_form| + next if valid_permission_form?(permission_form) + + permission_form.errors.add(:base, :invalid_ephemeral_participation_permissions, i18n_options) + end + end + + def resource_permissions? + resource_id.present? + end + + def ephemeral_participation_enabled? + component.ephemeral_participation_enabled? + end + + def valid_permission_form?(permission_form) + handler_names = permission_form.authorization_handlers.keys & component.organization.available_authorizations + + handler_names.exclude?(component.organization.ephemeral_participation_authorization) || handler_names.size == 1 + end + + def component + @component ||= Decidim::Component.find(component_id) + end + + def i18n_options + { + ephemeral_participation_authorization: I18n.t("decidim.authorization_handlers.#{component.organization.ephemeral_participation_authorization}.name"), + ephemeral_participation_enabled: I18n.t("decidim.components.#{component.manifest_name}.settings.global.ephemeral_participation_enabled"), + } + end + end + end + end +end diff --git a/decidim-ephemeral_participation/app/forms/decidim/ephemeral_participation/transfer_user_form_override.rb b/decidim-ephemeral_participation/app/forms/decidim/ephemeral_participation/transfer_user_form_override.rb new file mode 100644 index 0000000000..e3c36c236c --- /dev/null +++ b/decidim-ephemeral_participation/app/forms/decidim/ephemeral_participation/transfer_user_form_override.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + module TransferUserFormOverride + extend ActiveSupport::Concern + + included do + validate :unique_email + + private + + def unique_email + return if duplicates(email: email).none? + + errors.add(:email, :taken) + end + + def duplicates(where_clause) + Decidim::EphemeralParticipation::DuplicatedUsers.new( + organization: current_user.organization, + where_clause: where_clause, + ).query + end + end + end + end +end diff --git a/decidim-ephemeral_participation/app/forms/decidim/ephemeral_participation/update_organization_form_override.rb b/decidim-ephemeral_participation/app/forms/decidim/ephemeral_participation/update_organization_form_override.rb new file mode 100644 index 0000000000..d836c03281 --- /dev/null +++ b/decidim-ephemeral_participation/app/forms/decidim/ephemeral_participation/update_organization_form_override.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + module UpdateOrganizationFormOverride + extend ActiveSupport::Concern + + included do + alias :old_map_model :map_model + + attribute :available_authorizations, Object + + validate :validate_available_authorizations + + def map_model(model) + old_map_model(model) + new_map_model(model) + end + + def new_map_model(model) + self.available_authorizations = model.read_attribute(:available_authorizations) + self.available_authorizations = self.available_authorizations.map { |a| [a, {}] }.to_h if self.available_authorizations.is_a?(Array) + end + + def clean_available_authorizations + available_authorizations + end + + def before_validation + available_authorizations.transform_values! { |string| JSON.parse(string).presence }.compact! + end + + private + + def validate_available_authorizations + return unless available_authorizations.values.count { |hash| hash["allow_ephemeral_participation"] == true } > 1 + + errors.add(:available_authorizations, :invalid) + end + end + end + end +end diff --git a/decidim-ephemeral_participation/app/models/decidim/ephemeral_participation/component_override.rb b/decidim-ephemeral_participation/app/models/decidim/ephemeral_participation/component_override.rb new file mode 100644 index 0000000000..1321c5cc50 --- /dev/null +++ b/decidim-ephemeral_participation/app/models/decidim/ephemeral_participation/component_override.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + module ComponentOverride + extend ActiveSupport::Concern + + included do + def ephemeral_participation_enabled? + settings.try(:ephemeral_participation_enabled) == true + end + + # Given organization.ephemeral_participation_authorization == "valid_auth" + # permissions => {"vote"=>{"authorization_handlers"=>{"valid_auth"=>{}}}} + # ephemeral_participation_permissions => ["vote"] + def ephemeral_participation_permissions + @ephemeral_participation_permissions ||= begin + return [] unless ephemeral_participation_enabled? + return [] unless permissions.present? + + permissions.map do |action, authorization_handlers| + handler_names = authorization_handlers.values.flat_map(&:keys) + + action if handler_names.include?(organization.ephemeral_participation_authorization) + end.compact + end + end + end + end + end +end diff --git a/decidim-ephemeral_participation/app/models/decidim/ephemeral_participation/organization_override.rb b/decidim-ephemeral_participation/app/models/decidim/ephemeral_participation/organization_override.rb new file mode 100644 index 0000000000..520ef4fbf5 --- /dev/null +++ b/decidim-ephemeral_participation/app/models/decidim/ephemeral_participation/organization_override.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + module OrganizationOverride + extend ActiveSupport::Concern + + included do + # altough this might introduce some confusion it maintains compatibility accross the application + # for any code expecting to obtain an array + def available_authorizations + authorizations = read_attribute(:available_authorizations) + authorizations.is_a?(Array) ? authorizations : authorizations.keys + end + + def ephemeral_participation_authorization + read_attribute(:available_authorizations).key("allow_ephemeral_participation" => true) + end + end + end + end +end diff --git a/decidim-ephemeral_participation/app/models/decidim/ephemeral_participation/permission_action_override.rb b/decidim-ephemeral_participation/app/models/decidim/ephemeral_participation/permission_action_override.rb new file mode 100644 index 0000000000..c4fba8aead --- /dev/null +++ b/decidim-ephemeral_participation/app/models/decidim/ephemeral_participation/permission_action_override.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + module PermissionActionOverride + extend ActiveSupport::Concern + + included do + def disallowed? + @state == :disallowed + end + end + end + end +end diff --git a/decidim-ephemeral_participation/app/models/decidim/ephemeral_participation/user_override.rb b/decidim-ephemeral_participation/app/models/decidim/ephemeral_participation/user_override.rb new file mode 100644 index 0000000000..12280e4f93 --- /dev/null +++ b/decidim-ephemeral_participation/app/models/decidim/ephemeral_participation/user_override.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + module UserOverride + extend ActiveSupport::Concern + + included do + scope :ephemeral_participant, -> { managed.where("extended_data ? 'ephemeral_participation'") } + + def ephemeral_participant? + managed? && ephemeral_participation_data.present? + end + + def ephemeral_participation_data + extended_data.fetch("ephemeral_participation", {}) + end + + def verified_ephemeral_participant? + return false unless ephemeral_participant? + + Decidim::Authorization.exists?( + user: self, + name: ephemeral_participation_data["authorization_name"] + ) + end + + def verifiable_ephemeral_participant? + return false unless ephemeral_participant? + + Decidim::EphemeralParticipation::VerificationConflicts.for(self).none? + end + + def unverifiable_ephemeral_participant? + return false unless ephemeral_participant? + + Decidim::EphemeralParticipation::VerificationConflicts.for(self).any? + end + + def ephemeral_participation_verification_adapter + return nil unless ephemeral_participant? + + Decidim::Verifications::Adapter.from_element(ephemeral_participation_data["authorization_name"]) + end + end + end + end +end diff --git a/decidim-ephemeral_participation/app/permissions/decidim/ephemeral_participation/ephemeral_action_permissions_dictionary.rb b/decidim-ephemeral_participation/app/permissions/decidim/ephemeral_participation/ephemeral_action_permissions_dictionary.rb new file mode 100644 index 0000000000..4e327300a6 --- /dev/null +++ b/decidim-ephemeral_participation/app/permissions/decidim/ephemeral_participation/ephemeral_action_permissions_dictionary.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + class EphemeralActionPermissionsDictionary + COMPONENT_PERMISSIONS_DICTIONARY = { + "budgets" => { + "vote" => [ + { action: :vote, scope: :public, subject: :project }, + { action: :create, scope: :public, subject: :order }, + ] + } + } + + def self.for(component) + new(component).fetch + end + + def initialize(component) + @component = component + end + + def fetch + return {} unless @component && @component.ephemeral_participation_permissions.any? + + COMPONENT_PERMISSIONS_DICTIONARY.fetch(@component.manifest_name).select do |action, _| + @component.ephemeral_participation_permissions.include?(action) + end + end + end + end +end diff --git a/decidim-ephemeral_participation/app/permissions/decidim/ephemeral_participation/ephemeral_participation_permissions.rb b/decidim-ephemeral_participation/app/permissions/decidim/ephemeral_participation/ephemeral_participation_permissions.rb new file mode 100644 index 0000000000..3175cc8cfc --- /dev/null +++ b/decidim-ephemeral_participation/app/permissions/decidim/ephemeral_participation/ephemeral_participation_permissions.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require_relative "ephemeral_action_permissions_dictionary" + +module Decidim + module EphemeralParticipation + class EphemeralParticipationPermissions < DefaultPermissions + def permissions + return permission_action if regular_user? + return permission_action if permission_action.disallowed? + + if create_ephemeral_participant? + allow! if allowed_to_create_ephemeral_participant? + elsif update_ephemeral_participant? + allow! if allowed_to_update_ephemeral_participant? + elsif destroy_ephemeral_participant? + allow! if allowed_to_destroy_ephemeral_participant? + elsif update_profile? + disallow! unless allowed_to_update_profile? + else + disallow! unless allowed_ephemeral_participation? + end + + permission_action + end + + private + + def regular_user? + user && (not user.ephemeral_participant?) + end + + def create_ephemeral_participant? + permission_action.action == :create && + permission_action.scope == :public && + permission_action.subject == :ephemeral_participant + end + + def allowed_to_create_ephemeral_participant? + return true if user.nil? + return true if (not user.verified_ephemeral_participant?) + + false + end + + def destroy_ephemeral_participant? + permission_action.action == :destroy && + permission_action.scope == :public && + permission_action.subject == :ephemeral_participant + end + + def allowed_to_destroy_ephemeral_participant? + user && user == context[:current_user] + end + + def update_ephemeral_participant? + permission_action.action == :update && + permission_action.scope == :public && + permission_action.subject == :ephemeral_participant + end + + def allowed_to_update_ephemeral_participant? + user && user == context[:current_user] && user.verifiable_ephemeral_participant? + end + + def update_profile? + permission_action.action == :update_profile && + permission_action.scope == :public && + permission_action.subject == :user + end + + def allowed_to_update_profile? + verify_ephemeral_participant_path? && (not user.verified_ephemeral_participant?) + end + + def verify_ephemeral_participant_path? + Decidim::EphemeralParticipation::InformingRecognizer.new(context[:request], user).verify_ephemeral_participant_path? + end + + def decidim_verifiations + Decidim::Verifications::Engine.routes.url_helpers + end + + def allowed_ephemeral_participation? + return true if browsing_public_pages? + return true if changing_locales? + return true if user && user.verified_ephemeral_participant? && ephemeral_participation_permission_action? + + false + end + + def browsing_public_pages? + permission_action.scope == :public && [:read, :list].include?(permission_action.action) + end + + def changing_locales? + permission_action.action == :create && + permission_action.scope == :public && + permission_action.subject == :locales + end + + def ephemeral_participation_permission_action? + Decidim::EphemeralParticipation::EphemeralActionPermissionsDictionary.for(component) + .any? do |_, permission_action_attributes| + permission_action_attributes.any? do |action:, scope:, subject:| + permission_action.matches?(scope, action, subject) + end + end + end + end + end +end diff --git a/decidim-ephemeral_participation/app/permissions/decidim/ephemeral_participation/permissions_override.rb b/decidim-ephemeral_participation/app/permissions/decidim/ephemeral_participation/permissions_override.rb new file mode 100644 index 0000000000..b0d7377c9c --- /dev/null +++ b/decidim-ephemeral_participation/app/permissions/decidim/ephemeral_participation/permissions_override.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + module PermissionsOverride + extend ActiveSupport::Concern + + included do + alias :old_permissions :permissions + + def permissions + old_permissions + new_permissions + end + + private + + def new_permissions + Decidim::EphemeralParticipation::EphemeralParticipationPermissions.new(user, permission_action, context).permissions + end + end + end + end +end diff --git a/decidim-ephemeral_participation/app/presenter/decidim/ephemeral_participation/flash_messages_presenter.rb b/decidim-ephemeral_participation/app/presenter/decidim/ephemeral_participation/flash_messages_presenter.rb new file mode 100644 index 0000000000..428b7ebdfa --- /dev/null +++ b/decidim-ephemeral_participation/app/presenter/decidim/ephemeral_participation/flash_messages_presenter.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + class FlashMessagesPresenter + def initialize(user, view_helpers) + @user = user + @view_helpers = view_helpers + end + + def unauthorized_ephemeral_participant_message + I18n.t( + "decidim.ephemeral_participation.actions.unauthorized", + link: ( + @view_helpers.link_to( + I18n.t("decidim.ephemeral_participation.actions.unauthorized_link"), + decidim_ephemeral_participation.edit_ephemeral_participant_path(@user), + ) + ) + ).html_safe + end + + def verified_ephemeral_participant_message + I18n.t( + "decidim.ephemeral_participation.actions.verified", + link: @view_helpers.link_to( + I18n.t("decidim.ephemeral_participation.actions.verified_link"), + decidim_ephemeral_participation.edit_ephemeral_participant_path(@user), + ) + ).html_safe + end + + def edit_ephemeral_participant_path + Decidim::EphemeralParticipation::Engine.routes.url_helpers.edit_ephemeral_participant_path(@user) + end + + def unverified_ephemeral_participant_message + I18n.t( + "decidim.ephemeral_participation.actions.unverified", + link: @view_helpers.link_to( + I18n.t("decidim.ephemeral_participation.actions.unverified_link"), + verify_ephemeral_participant_path, + ) + ).html_safe + end + + def verify_ephemeral_participant_path + @user + .ephemeral_participation_verification_adapter + .root_path(redirect_url: @user.ephemeral_participation_data["request_path"]) + end + + def decidim_ephemeral_participation + Decidim::EphemeralParticipation::Engine.routes.url_helpers + end + end + end +end diff --git a/decidim-ephemeral_participation/app/presenter/decidim/ephemeral_participation/session_presenter.rb b/decidim-ephemeral_participation/app/presenter/decidim/ephemeral_participation/session_presenter.rb new file mode 100644 index 0000000000..d817907e48 --- /dev/null +++ b/decidim-ephemeral_participation/app/presenter/decidim/ephemeral_participation/session_presenter.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + class SessionPresenter + EPHEMERAL_PARTICIPANT_SESSION_DURATION = 30.minutes + + def initialize(user, view_helpers) + @user = user + @view_helpers = view_helpers + end + + def ephemeral_participant_session_remaining_time_in_minutes + (ephemeral_participant_session_remaining_time / 1.minute).round + end + + def ephemeral_participant_session_expired? + ephemeral_participant_session_remaining_time.negative? + end + + private + + def ephemeral_participant_session_remaining_time + (@user.created_at + EPHEMERAL_PARTICIPANT_SESSION_DURATION) - Time.current + end + end + end +end diff --git a/decidim-ephemeral_participation/app/queries/decidim/ephemeral_participation/duplicated_users.rb b/decidim-ephemeral_participation/app/queries/decidim/ephemeral_participation/duplicated_users.rb new file mode 100644 index 0000000000..64dc2f757f --- /dev/null +++ b/decidim-ephemeral_participation/app/queries/decidim/ephemeral_participation/duplicated_users.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + class DuplicatedUsers < Rectify::Query + def initialize(organization:, excluding: nil, where_clause:) + @organization = organization + @excluding = Array.wrap(excluding).compact.map(&:id) + @where_clause = where_clause + end + + def query + Decidim::User + .where(organization: @organization) + .where.not(id: @excluding) + .where(**@where_clause) + end + end + end +end diff --git a/decidim-ephemeral_participation/app/queries/decidim/ephemeral_participation/verification_conflicts.rb b/decidim-ephemeral_participation/app/queries/decidim/ephemeral_participation/verification_conflicts.rb new file mode 100644 index 0000000000..fc6645564a --- /dev/null +++ b/decidim-ephemeral_participation/app/queries/decidim/ephemeral_participation/verification_conflicts.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + class VerificationConflicts < Rectify::Query + def self.for(user) + new(user).query + end + + def initialize(user) + @user = user + end + + def query + Decidim::Verifications::Conflict.where(current_user: @user) + end + end + end +end diff --git a/decidim-ephemeral_participation/app/services/decidim/ephemeral_participation/informing_recognizer.rb b/decidim-ephemeral_participation/app/services/decidim/ephemeral_participation/informing_recognizer.rb new file mode 100644 index 0000000000..3d896da887 --- /dev/null +++ b/decidim-ephemeral_participation/app/services/decidim/ephemeral_participation/informing_recognizer.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + class InformingRecognizer + def initialize(request, user) + @request = request + @user = user + end + + def inform_unverified_ephemeral_participant? + informable_ephemeral_participant? && (not @user.verified_ephemeral_participant?) + end + + + def inform_verified_ephemeral_participant? + informable_ephemeral_participant? && @user.verified_ephemeral_participant? + end + + def informable_ephemeral_participant? + return false if verify_ephemeral_participant_path? + return false if edit_ephemeral_participant_path? + return false if @request.flash.any? + + true + end + + def verify_ephemeral_participant_path? + adapter = @user.ephemeral_participation_verification_adapter + engine = (adapter.type == "direct") ? Decidim::Verifications::Engine : adapter.engine + + engine.routes.recognize_path_with_request(@request, @request.path, method: @request.method) + rescue ActionController::RoutingError + false + end + + private + + def edit_ephemeral_participant_path? + @request.path == edit_ephemeral_participant_path + end + + def edit_ephemeral_participant_path + Decidim::EphemeralParticipation::FlashMessagesPresenter + .new(@user, nil) + .edit_ephemeral_participant_path + end + end + end +end diff --git a/decidim-ephemeral_participation/app/services/decidim/ephemeral_participation/redirection_recognizer.rb b/decidim-ephemeral_participation/app/services/decidim/ephemeral_participation/redirection_recognizer.rb new file mode 100644 index 0000000000..dd5fdfa7ed --- /dev/null +++ b/decidim-ephemeral_participation/app/services/decidim/ephemeral_participation/redirection_recognizer.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + class RedirectionRecognizer + def initialize(request, user) + @request = request + @user = user + end + + # Handles verification workflows redirecting to authorizations#index after creating authorization. + def redirect_to_ephemeral_participation_path? + @user.verified_ephemeral_participant? && + @request.method == "GET" && + @request.path == decidim_verifications.authorizations_path + end + + def redirect_to_edit_ephemeral_participant_path? + return true if path?(decidim.account_path) + return true if path?(decidim.notifications_settings_path) + return true if path?(decidim.data_portability_path) + return true if path?(decidim.own_user_groups_path) + return true if path?(decidim.user_interests_path) + return true if path?(decidim.profile_path(@user.nickname)) + return true if path?(decidim.notifications_path) + return true if path?(decidim.conversations_path) + + false + end + + private + + def path?(path) + @request.path.include?(path) + end + + def decidim_verifications + Decidim::Verifications::Engine.routes.url_helpers + end + + def decidim + Decidim::Core::Engine.routes.url_helpers + end + end + end +end diff --git a/decidim-ephemeral_participation/app/views/decidim/budgets/projects/_project_budget_button.html.erb b/decidim-ephemeral_participation/app/views/decidim/budgets/projects/_project_budget_button.html.erb new file mode 100644 index 0000000000..10fef00ebb --- /dev/null +++ b/decidim-ephemeral_participation/app/views/decidim/budgets/projects/_project_budget_button.html.erb @@ -0,0 +1,46 @@ +
+ <% if voted_for?(project) %> + <%= action_authorized_button_to( + "vote", + t(".added"), + budget_order_line_item_path(budget, project_id: project), + method: :delete, + remote: true, + data: { + disable: true, + budget: project.budget_amount, + "redirect-url": budget_project_path(budget, project) + }, + disabled: !can_have_order? || current_order_checked_out?, + class: "button expanded button--sc success", + "aria-label": t(".added_descriptive", resource_name: translated_attribute(project.title)) + ) %> + <% elsif current_user.present? && (!current_user.ephemeral_participant? || current_user.verified_ephemeral_participant?) %> + <%= action_authorized_button_to( + "vote", + t(".add"), + budget_order_line_item_path(budget, project_id: project), + method: :post, + remote: true, + data: { + disable: true, + budget: project.budget_amount, + add: true, + "redirect-url": budget_project_path(budget, project) + }, + disabled: !can_have_order? || current_order_checked_out?, + class: "button expanded button--sc", + "aria-label": t(".add_descriptive", resource_name: translated_attribute(project.title)) + ) %> + <% elsif current_user.present? && (current_user.ephemeral_participant? && !current_user.verified_ephemeral_participant?) %> + <%= link_to t(".add"), + Decidim::EphemeralParticipation::FlashMessagesPresenter.new(current_user, self).verify_ephemeral_participant_path, + class: "button expanded button--sc", + "aria-label": t(".add_descriptive", resource_name: translated_attribute(project.title)) + %> + <% else %> + + <% end %> +
diff --git a/decidim-ephemeral_participation/app/views/decidim/ephemeral_participation/ephemeral_participants/edit.html.erb b/decidim-ephemeral_participation/app/views/decidim/ephemeral_participation/ephemeral_participants/edit.html.erb new file mode 100644 index 0000000000..b97f17a37c --- /dev/null +++ b/decidim-ephemeral_participation/app/views/decidim/ephemeral_participation/ephemeral_participants/edit.html.erb @@ -0,0 +1,16 @@ + +
+ <%= decidim_form_for(@form, url: ephemeral_participant_path, method: :put, html: { autocomplete: "off" }) do |form| %> + +
+ <%= form.text_field :name %> + <%= form.text_field :nickname %> + <%= form.email_field :email %> + + <%= form.password_field :password, value: form.object.password, autocomplete: "off", help_text: I18n.t("devise.passwords.edit.password_help", minimun_characters: NOBSPW.configuration.min_password_length) %> + <%= form.password_field :password_confirmation, value: form.object.password_confirmation, autocomplete: "off" %> + + <%= form.submit I18n.t("submit", scope: "decidim.ephemeral_participation.ephemeral_participants") %> +
+ <% end %> +
diff --git a/decidim-ephemeral_participation/app/views/decidim/ephemeral_participation/shared/_login_modal.erb b/decidim-ephemeral_participation/app/views/decidim/ephemeral_participation/shared/_login_modal.erb new file mode 100644 index 0000000000..03b4c712c0 --- /dev/null +++ b/decidim-ephemeral_participation/app/views/decidim/ephemeral_participation/shared/_login_modal.erb @@ -0,0 +1,30 @@ +<% + if( + controller.respond_to?(:current_component) && + current_component.ephemeral_participation_enabled? && + current_component.ephemeral_participation_permissions.any? + ) +%> +
+
+ + <%= I18n.t("or", scope: "decidim.devise.shared.omniauth_buttons") %> + +
+ + <%= + button_to( + I18n.t("button", scope: "decidim.ephemeral_participation.login_modal"), + decidim_ephemeral_participation.ephemeral_participants_path( + component_id: current_component.id, + ephemeral_participation_path: request.path, + ), + class: "button expanded" + ) + %> + <%= I18n.t("help", scope: "decidim.ephemeral_participation.login_modal") %> + +
+
+
+<% end %> diff --git a/decidim-ephemeral_participation/app/views/decidim/shared/_login_modal.html.erb b/decidim-ephemeral_participation/app/views/decidim/shared/_login_modal.html.erb new file mode 100644 index 0000000000..4bb53666da --- /dev/null +++ b/decidim-ephemeral_participation/app/views/decidim/shared/_login_modal.html.erb @@ -0,0 +1,63 @@ +
+
+

<%= I18n.t("please_sign_in", scope: "decidim.shared.login_modal") %>

+ +
+ <% if current_organization.sign_in_enabled? %> +
+
+ <% + path = if content_for(:redirect_after_login) + session_path(:user, redirect_url: content_for(:redirect_after_login)) + else + session_path(:user) + end + %> + <%= decidim_form_for(Decidim::User.new, namespace: "login", as: :user, url: path, html: { class: "register-form new_user" }) do |f| %> +
+
+ <%= f.email_field :email %> +
+
+ <%= f.password_field :password, autocomplete: "off" %> +
+
+
+ <%= f.submit I18n.t("devise.sessions.new.sign_in"), class: "button expanded" %> +
+ <% end %> + <% if current_organization.sign_up_enabled? %> +

+ <%= link_to I18n.t("sign_up", scope: "decidim.shared.login_modal"), decidim.new_user_registration_path, class: "sign-up-link" %> +

+ <% end %> +

+ <%= link_to I18n.t("devise.shared.links.forgot_your_password"), new_password_path(:user) %> +

+
+
+ <% cache current_organization do %> + <%= render "decidim/devise/shared/omniauth_buttons_mini" %> + <% end %> + <% cache current_organization do %> + <%= render "decidim/ephemeral_participation/shared/login_modal" %> + <% end %> + <% else %> +
+
+

+ <%= I18n.t("sign_in_disabled", scope: "decidim.devise.sessions.new") %> +

+
+
+ <% cache current_organization do %> + <%= render "decidim/devise/shared/omniauth_buttons" %> + <% end %> + <% cache current_organization do %> + <%= render "decidim/ephemeral_participation/shared/login_modal" %> + <% end %> + <% end %> +
diff --git a/decidim-ephemeral_participation/app/views/decidim/system/organizations/_authorizations_settings.erb b/decidim-ephemeral_participation/app/views/decidim/system/organizations/_authorizations_settings.erb new file mode 100644 index 0000000000..dd61d4ad5f --- /dev/null +++ b/decidim-ephemeral_participation/app/views/decidim/system/organizations/_authorizations_settings.erb @@ -0,0 +1,61 @@ +
+ + + + + + + + + + <%= f.fields_for :available_authorizations, f.object.available_authorizations do |ff| %> + <%= f.error_for(:available_authorizations) %> + <% Decidim.authorization_workflows.each do |authorization_workflow| %> + + + + + + <% end %> + <% end %> + +
<%= f.label :available_authorizations %><%= f.label :enabled %><%= f.label :allows_ephemeral_participation %>
+ <%= ff.label authorization_workflow.description %> + + <%= ff.check_box( + authorization_workflow.name, # attribute + { + label: false, + id: "organization_available_authorizations_#{authorization_workflow.name}_enabled", + checked: f.object.available_authorizations&.key?(authorization_workflow.name) + }, # options + { allow_ephemeral_participation: false }.to_json, # checked_value + {}.to_json # unchecked_value + ) %> + + <%= ff.radio_button( + authorization_workflow.name, # attribute + { allow_ephemeral_participation: true }.to_json, # value + { + label: false, + id: "organization_available_authorizations_#{authorization_workflow.name}_allow_ephemeral_participation", + checked: f.object.available_authorizations&.dig(authorization_workflow.name, "allow_ephemeral_participation") == true, + disabled: !authorization_workflow.ephemerable, + class: ("hide" if !authorization_workflow.ephemerable) + } # options + ) %> +
+
+ + diff --git a/decidim-ephemeral_participation/app/views/decidim/system/organizations/edit.html.erb b/decidim-ephemeral_participation/app/views/decidim/system/organizations/edit.html.erb new file mode 100644 index 0000000000..0063d25239 --- /dev/null +++ b/decidim-ephemeral_participation/app/views/decidim/system/organizations/edit.html.erb @@ -0,0 +1,36 @@ +<%= decidim_form_for(@form) do |f| %> +
+ <%= f.text_field :name, autofocus: true %> +
+ +
+ <%= f.text_field :host %> +
+ +
+ <%= f.text_area :secondary_hosts %> +

<%= I18n.t("decidim.system.organizations.edit.secondary_hosts_hint") %>

+
+ +
+ <%= f.label :force_authentication %> + <%= f.check_box :force_users_to_authenticate_before_access_organization %> +
+ +
+ <%= f.label :users_registration_mode %> + <%= f.collection_radio_buttons :users_registration_mode, + Decidim::Organization.users_registration_modes, + :first, + ->(mode) { I18n.t("decidim.system.organizations.users_registration_mode.#{mode.first}") } %> +
+ + <%= render partial: "authorizations_settings", locals: { f: f } %> + <%= render partial: "smtp_settings", locals: { f: f } %> + <%= render partial: "omniauth_settings", locals: { f: f } %> + <%= render partial: "file_upload_settings", locals: { f: f } %> + +
+ <%= f.submit I18n.t("decidim.system.actions.save") %> +
+<% end %> diff --git a/decidim-ephemeral_participation/app/views/decidim/system/organizations/new.html.erb b/decidim-ephemeral_participation/app/views/decidim/system/organizations/new.html.erb new file mode 100644 index 0000000000..dbc2ca2dde --- /dev/null +++ b/decidim-ephemeral_participation/app/views/decidim/system/organizations/new.html.erb @@ -0,0 +1,77 @@ +<% provide :title do %> +

<%= t ".title" %>

+<% end %> + +<%= decidim_form_for(@form) do |f| %> +
+ <%= f.text_field :name, autofocus: true %> +
+ +
+ <%= f.text_field :reference_prefix %> +

<%= I18n.t("decidim.system.organizations.new.reference_prefix_hint") %>

+
+ +
+ <%= f.text_field :host %> +
+ +
+ <%= f.text_area :secondary_hosts %> +

<%= I18n.t("decidim.system.organizations.new.secondary_hosts_hint") %>

+
+ +
+ <%= f.text_field :organization_admin_name %> +
+ +
+ <%= f.email_field :organization_admin_email %> +
+ + <%= f.fields_for :locales do |fields| %> +
+ <%= f.label :organization_locales, "", class: @form.respond_to?(:errors) && @form.errors[:default_locale].present? ? "is-invalid-label" : "" %> + + + + + + + + + + <% localized_locales.each do |locale| %> + + + + + + <% end %> + +
LocaleEnabled <%= f.error_for(:available_locales) %>Default? <%= f.error_for(:default_locale) %>
<%= locale.name %><%= check_box_tag "organization[available_locales][#{locale.id}]", locale.id, @form.available_locales.include?(locale.id) %><%= radio_button_tag "organization[default_locale]", locale.id, @form.default_locale == locale.id %>
+
+ <% end %> + +
+ <%= f.label :force_authentication %> + <%= f.check_box :force_users_to_authenticate_before_access_organization %> +
+ +
+ <%= f.label :users_registration_mode %> + <%= f.collection_radio_buttons :users_registration_mode, + Decidim::Organization.users_registration_modes, + :first, + ->(mode) { I18n.t("decidim.system.organizations.users_registration_mode.#{mode.first}") } %> +
+ + <%= render partial: "authorizations_settings", locals: { f: f } %> + <%= render partial: "smtp_settings", locals: { f: f } %> + <%= render partial: "omniauth_settings", locals: { f: f } %> + <%= render partial: "file_upload_settings", locals: { f: f } %> + +
+ <%= f.submit I18n.t("decidim.system.models.organization.actions.save_and_invite") %> +
+<% end %> diff --git a/decidim-ephemeral_participation/app/views/layouts/decidim/_user_menu.html.erb b/decidim-ephemeral_participation/app/views/layouts/decidim/_user_menu.html.erb new file mode 100644 index 0000000000..c3e645e2a2 --- /dev/null +++ b/decidim-ephemeral_participation/app/views/layouts/decidim/_user_menu.html.erb @@ -0,0 +1,24 @@ +<% if current_user.ephemeral_participant? %> + <%= render partial: "layouts/decidim/ephemeral_participation/user_menu" %> +<% else %> + + <%# The code BELOW raises: Cannot use t(".profile") shortcut because path is not available %> + <%# + decidim_gem_dir = Gem::Specification.find_by_name("decidim").gem_dir + view_path = "decidim-core/app/views/layouts/decidim/_user_menu.html.erb" + decidim_core_user_menu_partial = "#{decidim_gem_dir}/#{view_path}" + %> + <%#= render file: decidim_core_user_menu_partial %> + <%# The code ABOVE raises: Cannot use t(".profile") shortcut because path is not available %> + +
  • <%= link_to I18n.t("profile", scope: "layouts.decidim.user_menu"), decidim.account_path, tabindex: "-1" %>
  • + <% if current_user.nickname.present? && !current_user.managed? %> +
  • <%= link_to I18n.t("public_profile", scope: "layouts.decidim.user_menu"), decidim.profile_path(current_user.nickname), tabindex: "-1" %>
  • + <% end %> +
  • <%= link_to I18n.t("notifications", scope: "layouts.decidim.user_menu"), decidim.notifications_path, tabindex: "-1" %>
  • +
  • <%= link_to I18n.t("conversations", scope: "layouts.decidim.user_menu"), decidim.conversations_path, tabindex: "-1" %>
  • + <% if allowed_to? :read, :admin_dashboard %> +
  • <%= link_to I18n.t("admin_dashboard", scope: "layouts.decidim.user_menu"), decidim_admin.root_path, tabindex: "-1" %>
  • + <% end %> +
  • <%= link_to I18n.t("sign_out", scope: "layouts.decidim.user_menu"), decidim.destroy_user_session_path, method: :delete, class: "sign-out-link", tabindex: "-1" %>
  • +<% end %> diff --git a/decidim-ephemeral_participation/app/views/layouts/decidim/ephemeral_participation/_user_menu.html.erb b/decidim-ephemeral_participation/app/views/layouts/decidim/ephemeral_participation/_user_menu.html.erb new file mode 100644 index 0000000000..43c7fe2852 --- /dev/null +++ b/decidim-ephemeral_participation/app/views/layouts/decidim/ephemeral_participation/_user_menu.html.erb @@ -0,0 +1,40 @@ +
  • + <%= + t( + "remaining", + scope: "decidim.ephemeral_participation.user_menu", + remaining: ( + Decidim::EphemeralParticipation::SessionPresenter + .new(current_user, self) + .ephemeral_participant_session_remaining_time_in_minutes + ) + ) + %> +
  • +<% if current_user.verifiable_ephemeral_participant? %> +
  • + <%= + link_to( + I18n.t("complete_registration", scope: "decidim.ephemeral_participation.user_menu"), + decidim_ephemeral_participation.edit_ephemeral_participant_path(current_user), + tabindex: "-1", + ) + %> +
  • +<% end %> +
  • + <%= + link_to( + I18n.t("sign_out", scope: "decidim.ephemeral_participation.user_menu"), + decidim_ephemeral_participation.ephemeral_participant_path(current_user, redirect_url: request.path), + method: :delete, + class: "sign-out-link", + tabindex: "-1" + ) + %> +
  • + +<%# + Maybe use JS to style dropdown and draw attention to it. + Also: would be nice to have a live countdown for the session. +%> diff --git a/decidim-ephemeral_participation/app/views/layouts/decidim/ephemeral_participation/user_profile.html.erb b/decidim-ephemeral_participation/app/views/layouts/decidim/ephemeral_participation/user_profile.html.erb new file mode 100644 index 0000000000..a3065c15e4 --- /dev/null +++ b/decidim-ephemeral_participation/app/views/layouts/decidim/ephemeral_participation/user_profile.html.erb @@ -0,0 +1,31 @@ +<%= render "layouts/decidim/application" do %> +
    +
    +
    +

    + <%= I18n.t("title", scope: "decidim.ephemeral_participation.ephemeral_participants") %> +

    +
    +
    +
    +
    +
    +
    +
    +
      + <%#= user_menu.render %> +
    +
    +
    +
    +
    +
    + <%= yield %> +
    +
    +
    +
    +
    +
    +
    +<% end %> diff --git a/decidim-ephemeral_participation/config/locales/ca.yml b/decidim-ephemeral_participation/config/locales/ca.yml new file mode 100644 index 0000000000..4e7ce351dc --- /dev/null +++ b/decidim-ephemeral_participation/config/locales/ca.yml @@ -0,0 +1,51 @@ +--- +ca: + activemodel: + attributes: + organization: + enabled: Actiu + allows_ephemeral_participation: Permet participació sense registre + errors: + models: + organization: + attributes: + available_authorizations: + invalid: Only one authorization method can be used to allow ephemeral participation + permission: + attributes: + base: + invalid_ephemeral_participation_permissions: Cannot set permissions using multiple authorizations when '%{ephemeral_participation_authorization}' authorization is selected and '%{ephemeral_participation_enabled}' component setting is enabled. + settings: + attributes: + ephemeral_participation_enabled: + missing_ephemeral_participation_authorization: Must enable ephemeral participation authorization at system level. + decidim: + authorization_handlers: + ephemerable: Permet participació directe + ephemeral_participation: + actions: + unauthorized: "No tens permís per realitzar aquesta acció: %{link}" + unauthorized_link: Completa el teu registre aquí. + unverified: "Per poder participar, cal que estiguis verificada: %{link}" + unverified_link: Completa el procés de verificació aquí. + verified: "Completa el teu registre %{link}" + verified_link: aquí. + login_modal: + button: Vull participar sense registrar-me + help: Fes servir aquesta opció per una participació puntual + user_menu: + remaining: "%{remaining} min. per la desconexió automàtica" + sign_out: Cancel·la i desconnecta + complete_registration: Completa el teu registre + ephemeral_participants: + create: Per poder participar sense registre, has de completar el procés de verificació + destroy: S'ha cancel·lat la participació sense registre + submit: Envia + title: Completa el teu perfil d'usuari per simplificar la teva participació en el futur + unverifiable: No ha estat possible completar la verificació. Pots tornar a intentar-ho en un altre moment. + components: + budgets: + settings: + global: + ephemeral_participation_enabled: Ephemeral participation enabled + ephemeral_participation_enabled_help: Allows users to participate without registration. Requires configuring permissions. diff --git a/decidim-ephemeral_participation/config/locales/en.yml b/decidim-ephemeral_participation/config/locales/en.yml new file mode 100644 index 0000000000..6b195b5f7f --- /dev/null +++ b/decidim-ephemeral_participation/config/locales/en.yml @@ -0,0 +1,51 @@ +--- +en: + activemodel: + attributes: + organization: + enabled: Enabled + allows_ephemeral_participation: Allows participation without registering + errors: + models: + permission: + attributes: + base: + invalid_ephemeral_participation_permissions: Cannot set permissions using multiple authorizations when '%{ephemeral_participation_authorization}' authorization is selected and '%{ephemeral_participation_enabled}' component setting is enabled. + organization: + attributes: + available_authorizations: + invalid: Only one authorization method can be used to allow ephemeral participation + settings: + attributes: + ephemeral_participation_enabled: + missing_ephemeral_participation_authorization: Must enable ephemeral participation authorization at system level. + decidim: + authorization_handlers: + ephemerable: Allows direct participation + ephemeral_participation: + actions: + unauthorized: "You are not authorized to perform this action: %{link}" + unauthorized_link: Finish your registration here. + unverified: "You need to be verified in order tor participate: %{link}" + unverified_link: Complete the verification process here. + verified: "Finish your registration %{link}" + verified_link: here. + login_modal: + button: I want to participate without registering + help: Use this option for a one-time participation + user_menu: + remaining: "%{remaining} min. before automatic sign out" + sign_out: Cancel and sign out + complete_registration: Finish your registration + ephemeral_participants: + create: en.decidim.ephemeral_participation.ephemeral_participants.create + destroy: en.decidim.ephemeral_participation.ephemeral_participants.destroy + submit: Send + title: Complete your user profile for easily future participation + unverifiable: The verification process has been unsuccessful. You can try it again later. + components: + budgets: + settings: + global: + ephemeral_participation_enabled: Ephemeral participation enabled + ephemeral_participation_enabled_help: Allows users to participate without registration. Requires configuring permissions. diff --git a/decidim-ephemeral_participation/config/locales/es.yml b/decidim-ephemeral_participation/config/locales/es.yml new file mode 100644 index 0000000000..f8bb50be2f --- /dev/null +++ b/decidim-ephemeral_participation/config/locales/es.yml @@ -0,0 +1,51 @@ +--- +es: + activemodel: + attributes: + organization: + enabled: Activo + allows_ephemeral_participation: Permite la participación sin registro + errors: + models: + permission: + attributes: + base: + invalid_ephemeral_participation_permissions: Cannot set permissions using multiple authorizations when '%{ephemeral_participation_authorization}' authorization is selected and '%{ephemeral_participation_enabled}' component setting is enabled. + organization: + attributes: + available_authorizations: + invalid: Only one authorization method can be used to allow ephemeral participation + settings: + attributes: + ephemeral_participation_enabled: + missing_ephemeral_participation_authorization: Must enable ephemeral participation authorization at system level. + decidim: + authorization_handlers: + ephemerable: Permite participación directa + ephemeral_participation: + actions: + unauthorized: "No tienes permisos para realizar esta acción: %{link}" + unauthorized_link: Completa tu registro aquí. + unverified: "Para poder participar, es necesario que estés verificada: %{link}" + unverified_link: Completa el proceos de verificación aquí. + verified: "Completa tu registro %{link}" + verified_link: aquí. + login_modal: + button: Quiero participar sin registrarme + help: Utiliza esta opción para una participación puntual + user_menu: + remaining: "%{remaining} min. para desconexión automática" + sign_out: Cancela y desconecta + complete_registration: Completa tu registro + ephemeral_participants: + create: es.decidim.ephemeral_participation.ephemeral_participants.create + destroy: Se ha cancelado la participación sin registro + submit: Enviar + title: Completa tu perfil de usuario para simplificar tu participación en el futuro + unverifiable: unverifiable es + components: + budgets: + settings: + global: + ephemeral_participation_enabled: Ephemeral participation enabled + ephemeral_participation_enabled_help: Allows users to participate without registration. Requires configuring permissions. diff --git a/decidim-ephemeral_participation/db/migrate/20210518192857_update_organizations_available_authorizations.rb b/decidim-ephemeral_participation/db/migrate/20210518192857_update_organizations_available_authorizations.rb new file mode 100644 index 0000000000..fc7801089a --- /dev/null +++ b/decidim-ephemeral_participation/db/migrate/20210518192857_update_organizations_available_authorizations.rb @@ -0,0 +1,38 @@ +class UpdateOrganizationsAvailableAuthorizations < ActiveRecord::Migration[5.2] + class Organization < ApplicationRecord + self.table_name = :decidim_organizations + end + + def up + workflows = {} + + Organization.find_each do |organization| + workflows[organization.id] = + organization.available_authorizations.each_with_object({}) do |workflow, hash| + hash[workflow] = { allow_ephemeral_participation: false } + end + end + + remove_column :decidim_organizations, :available_authorizations + add_column :decidim_organizations, :available_authorizations, :jsonb, default: {} + + Organization.find_each do |organization| + organization.update!(available_authorizations: workflows[organization.id]) + end + end + + def down + workflows = {} + + Organization.find_each do |organization| + workflows[organization.id] = organization.available_authorizations.keys + end + + remove_column :decidim_organizations, :available_authorizations + add_column :decidim_organizations, :available_authorizations, :string, array: true, default: [] + + Organization.find_each do |organization| + organization.update!(available_authorizations: workflows[organization.id]) + end + end +end diff --git a/decidim-ephemeral_participation/decidim-ephemeral_participation.gemspec b/decidim-ephemeral_participation/decidim-ephemeral_participation.gemspec new file mode 100644 index 0000000000..86f578dd48 --- /dev/null +++ b/decidim-ephemeral_participation/decidim-ephemeral_participation.gemspec @@ -0,0 +1,18 @@ +# frozen_string_literal: true +$LOAD_PATH.push File.expand_path("../lib", __FILE__) + +# Describe your gem and declare its dependencies: +Gem::Specification.new do |s| + s.name = "decidim-ephemeral_participation" + s.summary = "A decidim module that allows users to participate without registration." + s.description = s.summary + s.version = "0.0.1" + s.authors = ["Ivan Vergés"] + s.email = ["ivan@platoniq.net"] + + s.files = Dir["{app,config,db,lib}/**/*", "Rakefile", "README.md"] + + s.add_dependency "decidim-verifications" + + s.add_development_dependency "decidim-dev" +end diff --git a/decidim-ephemeral_participation/lib/decidim/ephemeral_participation.rb b/decidim-ephemeral_participation/lib/decidim/ephemeral_participation.rb new file mode 100644 index 0000000000..4a0bb4b2a9 --- /dev/null +++ b/decidim-ephemeral_participation/lib/decidim/ephemeral_participation.rb @@ -0,0 +1,7 @@ +require "decidim/ephemeral_participation/engine" +require "decidim/ephemeral_participation/verifications_workflow_manifest_override" + +module Decidim + module EphemeralParticipation + end +end diff --git a/decidim-ephemeral_participation/lib/decidim/ephemeral_participation/engine.rb b/decidim-ephemeral_participation/lib/decidim/ephemeral_participation/engine.rb new file mode 100644 index 0000000000..c26599c6f5 --- /dev/null +++ b/decidim-ephemeral_participation/lib/decidim/ephemeral_participation/engine.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + class Engine < ::Rails::Engine + isolate_namespace Decidim::EphemeralParticipation + + config.to_prepare do + # commands + Decidim::Admin::TransferUser.include(Decidim::EphemeralParticipation::TransferUserOverride) + # controllers + Decidim::ApplicationController.include(Decidim::EphemeralParticipation::ApplicationControllerOverride) + Decidim::Admin::ConflictsController.include(Decidim::EphemeralParticipation::ConflictsControllerOverride) + # forms + Decidim::Admin::ComponentForm.include(Decidim::EphemeralParticipation::ComponentFormOverride) + Decidim::Admin::PermissionsForm.include(Decidim::EphemeralParticipation::PermissionsFormOverride) + Decidim::Admin::TransferUserForm.include(Decidim::EphemeralParticipation::TransferUserFormOverride) + Decidim::System::UpdateOrganizationForm.include(Decidim::EphemeralParticipation::UpdateOrganizationFormOverride) + # models + Decidim::Component.include(Decidim::EphemeralParticipation::ComponentOverride) + Decidim::Organization.include(Decidim::EphemeralParticipation::OrganizationOverride) + Decidim::PermissionAction.include(Decidim::EphemeralParticipation::PermissionActionOverride) + Decidim::User.include(Decidim::EphemeralParticipation::UserOverride) + # permissions + Decidim::Permissions.include(Decidim::EphemeralParticipation::PermissionsOverride) + # budgets states + Decidim::Budgets::ProjectListItemCell.include(Decidim::EphemeralParticipation::ProjectListItemCellOverride) + + end + + initializer "ephemeral_participation.component_override" do + Decidim.component_registry.find(:budgets).tap do |component| + component.settings(:global) do |settings| + settings.attribute(:ephemeral_participation_enabled, type: :boolean, default: false) + end + end + end + + routes do + scope :ephemeral_participation do + resources :ephemeral_participants, only: [:create, :edit, :update, :destroy] + end + end + end + end +end diff --git a/decidim-ephemeral_participation/lib/decidim/ephemeral_participation/verifications_workflow_manifest_override.rb b/decidim-ephemeral_participation/lib/decidim/ephemeral_participation/verifications_workflow_manifest_override.rb new file mode 100644 index 0000000000..dc9b6e2599 --- /dev/null +++ b/decidim-ephemeral_participation/lib/decidim/ephemeral_participation/verifications_workflow_manifest_override.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Decidim + module EphemeralParticipation + module VerificationsWorkflowManifestOverride + extend ActiveSupport::Concern + + included do + # Allows to be configured (in /system) for ephermeral participation (where available) + attribute :ephemerable, Virtus::Attribute::Boolean, default: false + + def description + ephemerable_text = ", #{I18n.t("ephemerable", scope: "decidim.authorization_handlers")}" if ephemerable + "#{fullname} (#{I18n.t(type, scope: "decidim.authorization_handlers")}#{ephemerable_text})" + end + end + end + end +end + +# needs to be available in initializers +Decidim::Verifications::WorkflowManifest.include(Decidim::EphemeralParticipation::VerificationsWorkflowManifestOverride) diff --git a/decidim-valid_auth/app/controllers/decidim/valid_auth/authorizations_controller.rb b/decidim-valid_auth/app/controllers/decidim/valid_auth/authorizations_controller.rb index b9be835dea..c0078cd7ae 100644 --- a/decidim-valid_auth/app/controllers/decidim/valid_auth/authorizations_controller.rb +++ b/decidim-valid_auth/app/controllers/decidim/valid_auth/authorizations_controller.rb @@ -43,4 +43,4 @@ def load_authorization end end end -end \ No newline at end of file +end diff --git a/lib/budgets_workflow_pam2020.rb b/lib/budgets_workflow_pam2020.rb index 216e880b2d..d9b5f0d25a 100644 --- a/lib/budgets_workflow_pam2020.rb +++ b/lib/budgets_workflow_pam2020.rb @@ -52,8 +52,8 @@ def user_authorization def user_scope_resource return unless user_authorization_scope - @user_scope_resource ||= budgets.each do |resource| - return resource if resource.scope == user_authorization_scope + @user_scope_resource ||= budgets.find do |resource| + resource.scope == user_authorization_scope end end diff --git a/lib/budgets_workflow_pam2021.rb b/lib/budgets_workflow_pam2021.rb new file mode 100644 index 0000000000..209c6ae28c --- /dev/null +++ b/lib/budgets_workflow_pam2021.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +# Specific Workflow for Barcelona's 2021 PAM +class BudgetsWorkflowPam2021 < Decidim::Budgets::Workflows::Base + PAM2021AUTHORIZATIONHANDLER = 'census_sms_authorization_handler' + + # The budget resource in the user's scope is highlighted. + def highlighted?(resource) + return unless user_scope_resource && !voted?(user_scope_resource) + + resource == user_scope_resource + end + + # Can vote in the budget resource in the user's scope + # and in an extra budget resource out of its scope + def vote_allowed?(resource, consider_progress = true) + return true if resource == user_scope_resource + + resources_with_order = voted + resources_with_order += progress if consider_progress + + (resources_with_order - [user_scope_resource, resource]).empty? + end + + # The user can change of mind and change the vote on these budget resources + # + # Returns Array. + def discardable + (voted + progress) - [user_scope_resource] + end + + # The user can vote on maximum 2 budget resources + # + # Returns Boolean. + def limit_reached? + (voted + progress).count < 3 + end + + private + + # Returns Object (Authorization). + def user_authorization + @user_authorization ||= Decidim::Authorization.find_by( + name: PAM2021AUTHORIZATIONHANDLER, + user: user + ) + end + + # The budget resources the user can and should vote on + # + # Returns Object (Decidim::Budgets:Budget). + def user_scope_resource + return unless user_authorization_scope + + @user_scope_resource ||= budgets.find do |resource| + resource.scope == user_authorization_scope + end + end + + # The user's scope from the verifcation + # + # Returns Object (Scope). + def user_authorization_scope + return unless user_authorization + + @user_authorization_scope ||= Decidim::Scope.find_by( + "name->>'ca' = '#{user_authorization.metadata['scope']}'" + ) + end +end diff --git a/spec/system/census16_authorization_spec.rb b/spec/system/census16_authorization_spec.rb index e0641ebca5..99359acf8f 100644 --- a/spec/system/census16_authorization_spec.rb +++ b/spec/system/census16_authorization_spec.rb @@ -13,7 +13,7 @@ ) end - let(:authorizations) { ["census16_authorization_handler"] } + let(:authorizations) { {"census16_authorization_handler" => {"allow_ephemeral_participation" => true}} } let!(:scope) { create :scope, organization: organization, code: "1" } let(:response) do diff --git a/spec/system/census_authorization_spec.rb b/spec/system/census_authorization_spec.rb index e102102e19..329e2d441f 100644 --- a/spec/system/census_authorization_spec.rb +++ b/spec/system/census_authorization_spec.rb @@ -13,7 +13,7 @@ ) end - let(:authorizations) { ["census_authorization_handler"] } + let(:authorizations) { {"census_authorization_handler" => {"allow_ephemeral_participation" => true}} } let!(:scope) { create :scope, organization: organization, code: "1" } let(:response) do diff --git a/spec/system/census_sms_authorization_spec.rb b/spec/system/census_sms_authorization_spec.rb new file mode 100644 index 0000000000..2e316a9f05 --- /dev/null +++ b/spec/system/census_sms_authorization_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Census + SMS authorization", type: :system, perform_enqueued: true, with_authorization_workflows: ["census_sms_authorization_handler"] do + let(:organization) do + create( + :organization, + name: "Ajuntament", + default_locale: :ca, + available_locales: [:es, :ca], + available_authorizations: authorizations + ) + end + + let(:authorization_name) { "El padró + SMS" } + let(:authorizations) { {"census_sms_authorization_handler" => {"allow_ephemeral_participation" => true}} } + let(:code) { user_authorization.verification_metadata["verification_code"] } + let(:user_authorization) { Decidim::Authorization.find_by(user: user, name: "census_sms_authorization_handler") } + + let!(:scope) { create :scope, organization: organization, code: "1" } + + let(:response) do + Nokogiri::XML("01").remove_namespaces! + end + + # Selects a birth date that will not cause errors in the form: January 12, 1979. + def fill_in_authorization_form + select "DNI", from: "authorization_document_type" + fill_in "authorization_document_number", with: "12345678A" + select "12", from: "authorization_date_of_birth_3i" + select "Gener", from: "authorization_date_of_birth_2i" + select "1979", from: "authorization_date_of_birth_1i" + fill_in "authorization_postal_code", with: "08001" + fill_in "authorization_mobile_phone_number", with: "(+34) 654 321 987" + check "authorization_tos_acceptance" + select translated(scope.name), from: "authorization_scope_id" + end + + before do + allow_any_instance_of(Decidim::CensusSms::Verification::AuthorizationForm).to receive(:response).and_return(response) + switch_to_host(organization.host) + end + + context "when visiting authorizations" do + let(:user) { create(:user, :confirmed, organization: organization) } + + before do + login_as user, scope: :user + visit decidim.root_path + end + + it "allows the user to authorize against available authorizations" do + within_user_menu do + click_link "El meu compte" + end + + click_link "Autoritzacions" + click_link authorization_name + + fill_in_authorization_form + click_button "Verifica't" + + expect(page).to have_content("Has completat el primer pas") + + fill_in "confirmation_verification_code", with: code + click_button "Verifica't" + + expect(page).to have_content("T'has verificat correctament") + + visit decidim_verifications.authorizations_path + + within ".authorizations-list" do + expect(page).to have_content(authorization_name) + expect(page).not_to have_link(authorization_name) + end + end + + it "allows the user to reset the verification code" do + within_user_menu do + click_link "El meu compte" + end + + click_link "Autoritzacions" + click_link authorization_name + + fill_in_authorization_form + click_button "Verifica't" + + click_link "Restableix el codi de verificació" + + fill_in "reset[mobile_phone_number]", with: "(+34) 654 321 987" + click_button "Envia'm un nou codi" + + expect(page).to have_content("T'hem enviat un nou codi de verificació") + + fill_in "confirmation_verification_code", with: code + click_button "Verifica't" + + expect(page).to have_content("T'has verificat correctament") + + visit decidim_verifications.authorizations_path + + within ".authorizations-list" do + expect(page).to have_content(authorization_name) + expect(page).not_to have_link(authorization_name) + end + end + + context "when the user has completed the first authorization step" do + let!(:code) { "012345" } + let!(:authorization) { create(:authorization, :pending, name: "census_sms_authorization_handler", user: user, verification_metadata: { verification_code: code, code_sent_at: Time.current }) } + + it "can resume the authorization" do + visit decidim_verifications.authorizations_path + + click_link authorization_name + + expect(page).to have_content("Introdueix el codi") + + fill_in "confirmation_verification_code", with: code + click_button "Verifica't" + + expect(page).to have_content("T'has verificat correctament") + end + end + + context "when the user has already been authorised" do + let!(:authorization) { create(:authorization, name: "census_sms_authorization_handler", user: user) } + + it "shows the authorization at their account" do + visit decidim_verifications.authorizations_path + + within ".authorizations-list" do + expect(page).to have_content(authorization_name) + expect(page).to have_content(I18n.localize(authorization.granted_at, format: :long, locale: :ca)) + end + end + end + end +end