diff --git a/Gemfile.lock b/Gemfile.lock index a76117296..bc5731471 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -490,7 +490,7 @@ GEM byebug (~> 11.0) pry (~> 0.13.0) public_suffix (4.0.6) - puma (5.3.1) + puma (5.5.1) nio4r (~> 2.0) pundit (2.1.0) activesupport (>= 3.0.0) diff --git a/app/assets/stylesheets/cnaf.scss b/app/assets/stylesheets/cnaf.scss new file mode 100644 index 000000000..9cbd7ea36 --- /dev/null +++ b/app/assets/stylesheets/cnaf.scss @@ -0,0 +1,30 @@ +@import "constants"; +@import "colors"; + +table.cnaf { + margin: 2 * $default-padding 0 $default-padding $default-padding; + width: 100%; + + caption { + font-weight: bold; + margin-left: - $default-padding; + margin-bottom: $default-spacer; + text-align: left; + } + + th, + td { + font-weight: normal; + padding: $default-spacer; + } + + th.text-right { + text-align: right; + } + + &.horizontal { + th { + border-bottom: 1px solid $grey; + } + } +} diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss index a21886db7..c815ac74d 100644 --- a/app/assets/stylesheets/forms.scss +++ b/app/assets/stylesheets/forms.scss @@ -241,6 +241,7 @@ } input[type=email], + input[type=password], input[type=number], input[type=tel], { max-width: 500px; @@ -356,6 +357,17 @@ } } + .cnaf-inputs { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + max-width: 700px; + + input { + width: inherit; + } + } + input.aa-input, input.aa-hint { border-radius: 4px; diff --git a/app/assets/stylesheets/france-connect-informations.scss b/app/assets/stylesheets/france-connect-informations.scss new file mode 100644 index 000000000..739c9734f --- /dev/null +++ b/app/assets/stylesheets/france-connect-informations.scss @@ -0,0 +1,12 @@ +@import "constants"; + +.france-connect-informations.card { + width: 100%; + padding-top: $default-spacer; + padding-bottom: $default-spacer; +} + +.france-connect-informations-logo img { + width: 100px; + margin-right: $default-padding; +} diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 9553e307f..072c3f483 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -85,6 +85,10 @@ class ApplicationController < ActionController::Base end end + def ajax_redirect(path) + "window.location.href='#{path}'" + end + protected def feature_enabled?(feature_name) diff --git a/app/controllers/france_connect/particulier_controller.rb b/app/controllers/france_connect/particulier_controller.rb index 595a50e78..40625c23f 100644 --- a/app/controllers/france_connect/particulier_controller.rb +++ b/app/controllers/france_connect/particulier_controller.rb @@ -1,5 +1,6 @@ class FranceConnect::ParticulierController < ApplicationController before_action :redirect_to_login_if_fc_aborted, only: [:callback] + before_action :securely_retrieve_fci, only: [:merge, :merge_with_existing_account, :merge_with_new_account] def login if FranceConnectService.enabled? @@ -11,23 +12,88 @@ class FranceConnect::ParticulierController < ApplicationController def callback fci = FranceConnectService.find_or_retrieve_france_connect_information(params[:code]) - fci.associate_user! - if fci.user && !fci.user.can_france_connect? - fci.destroy - redirect_to new_user_session_path, alert: t('errors.messages.france_connect.forbidden_html', reset_link: new_user_password_path) - return + if fci.user.nil? + preexisting_unlinked_user = User.find_by(email: fci.email_france_connect.downcase) + + if preexisting_unlinked_user.nil? + fci.associate_user!(fci.email_france_connect) + connect_france_connect_particulier(fci.user) + else + redirect_to france_connect_particulier_merge_path(fci.create_merge_token!) + end + else + user = fci.user + + if user.can_france_connect? + fci.update(updated_at: Time.zone.now) + connect_france_connect_particulier(user) + else + fci.destroy + redirect_to new_user_session_path, alert: t('errors.messages.france_connect.forbidden_html', reset_link: new_user_password_path) + end end - connect_france_connect_particulier(fci.user) - rescue Rack::OAuth2::Client::Error => e Rails.logger.error e.message redirect_france_connect_error_connection end + def merge + end + + def merge_with_existing_account + user = User.find_by(email: sanitized_email_params) + + if user.valid_for_authentication? { user.valid_password?(password_params) } + if !user.can_france_connect? + flash.alert = "#{user.email} ne peut utiliser FranceConnect" + + render js: ajax_redirect(root_path) + else + @fci.update(user: user) + @fci.delete_merge_token! + + flash.notice = "Les comptes FranceConnect et #{APPLICATION_NAME} sont à présent fusionnés" + connect_france_connect_particulier(user) + end + else + flash.alert = 'Mauvais mot de passe' + + render js: helpers.render_flash + end + end + + def merge_with_new_account + user = User.find_by(email: sanitized_email_params) + + if user.nil? + @fci.associate_user!(sanitized_email_params) + @fci.delete_merge_token! + + flash.notice = "Les comptes FranceConnect et #{APPLICATION_NAME} sont à présent fusionnés" + connect_france_connect_particulier(@fci.user) + else + @email = sanitized_email_params + @merge_token = merge_token_params + end + end + private + def securely_retrieve_fci + @fci = FranceConnectInformation.find_by(merge_token: merge_token_params) + + if @fci.nil? || !@fci.valid_for_merge? + flash.alert = 'Votre compte FranceConnect a expiré, veuillez recommencer.' + + respond_to do |format| + format.html { redirect_to root_path } + format.js { render js: ajax_redirect(root_path) } + end + end + end + def redirect_to_login_if_fc_aborted if params[:code].blank? redirect_to new_user_session_path @@ -39,23 +105,32 @@ class FranceConnect::ParticulierController < ApplicationController sign_out :user end - if instructeur_signed_in? - sign_out :instructeur - end - - if administrateur_signed_in? - sign_out :administrateur - end - sign_in user user.update_attribute('loged_in_with_france_connect', User.loged_in_with_france_connects.fetch(:particulier)) - redirect_to stored_location_for(current_user) || root_path(current_user) + redirection_location = stored_location_for(current_user) || root_path(current_user) + + respond_to do |format| + format.html { redirect_to redirection_location } + format.js { render js: ajax_redirect(root_path) } + end end def redirect_france_connect_error_connection flash.alert = t('errors.messages.france_connect.connexion') redirect_to(new_user_session_path) end + + def merge_token_params + params[:merge_token] + end + + def password_params + params[:password] + end + + def sanitized_email_params + params[:email]&.gsub(/[[:space:]]/, ' ')&.strip&.downcase + end end diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index 45659b21a..721ddf54b 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -337,8 +337,8 @@ module Users def champs_params params.permit(dossier: { champs_attributes: [ - :id, :value, :external_id, :primary_value, :secondary_value, :piece_justificative_file, value: [], - champs_attributes: [:id, :_destroy, :value, :external_id, :primary_value, :secondary_value, :piece_justificative_file, value: []] + :id, :value, :external_id, :primary_value, :secondary_value, :numero_allocataire, :code_postal, :piece_justificative_file, value: [], + champs_attributes: [:id, :_destroy, :value, :external_id, :primary_value, :secondary_value, :numero_allocataire, :code_postal, :piece_justificative_file, value: []] ] }) end @@ -382,7 +382,8 @@ module Users if @dossier.champs.any?(&:changed_for_autosave?) @dossier.last_champ_updated_at = Time.zone.now end - if !@dossier.save + + if !@dossier.save(**validation_options) errors += @dossier.errors.full_messages elsif change_groupe_instructeur? @dossier.assign_to_groupe_instructeur(groupe_instructeur_from_params) @@ -453,5 +454,16 @@ module Users def save_draft? dossier.brouillon? && !params[:submit_draft] end + + def validation_options + if save_draft? + { context: :brouillon } + else + # rubocop:disable Lint/BooleanSymbol + # Force ActiveRecord to re-validate associated records. + { context: :false } + # rubocop:enable Lint/BooleanSymbol + end + end end end diff --git a/app/graphql/schema.graphql b/app/graphql/schema.graphql index 5f73dbc34..24e02e2d3 100644 --- a/app/graphql/schema.graphql +++ b/app/graphql/schema.graphql @@ -1869,6 +1869,11 @@ enum TypeDeChamp { """ civilite + """ + Données de la Caisse nationale des allocations familiales + """ + cnaf + """ Communes """ diff --git a/app/helpers/procedure_helper.rb b/app/helpers/procedure_helper.rb index f8ffca559..4d64efe0b 100644 --- a/app/helpers/procedure_helper.rb +++ b/app/helpers/procedure_helper.rb @@ -13,7 +13,7 @@ module ProcedureHelper safe_join(parts, ' ') end - def procedure_publish_text(procedure, key) + def procedure_publish_label(procedure, key) # i18n-tasks-use t('modal.publish.body.publish') # i18n-tasks-use t('modal.publish.body.reopen') # i18n-tasks-use t('modal.publish.submit.publish') diff --git a/app/javascript/new_design/fc-fusion.js b/app/javascript/new_design/fc-fusion.js new file mode 100644 index 000000000..e9d4b7628 --- /dev/null +++ b/app/javascript/new_design/fc-fusion.js @@ -0,0 +1,19 @@ +import { show, hide } from '@utils'; + +export function showFusion() { + show(document.querySelector('.fusion')); + hide(document.querySelector('.new-account')); + hide(document.querySelector('.new-account-password-confirmation')); +} + +export function showNewAccount() { + hide(document.querySelector('.fusion')); + show(document.querySelector('.new-account')); + hide(document.querySelector('.new-account-password-confirmation')); +} + +export function showNewAccountPasswordConfirmation() { + hide(document.querySelector('.fusion')); + hide(document.querySelector('.new-account')); + show(document.querySelector('.new-account-password-confirmation')); +} diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 472d6ebc9..bfaa7cda4 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -41,6 +41,11 @@ import { acceptEmailSuggestion, discardEmailSuggestionBox } from '../new_design/user-sign_up'; +import { + showFusion, + showNewAccount, + showNewAccountPasswordConfirmation +} from '../new_design/fc-fusion'; // This is the global application namespace where we expose helpers used from rails views const DS = { @@ -49,6 +54,9 @@ const DS = { showMotivation, motivationCancel, showImportJustificatif, + showFusion, + showNewAccount, + showNewAccountPasswordConfirmation, replaceSemicolonByComma, acceptEmailSuggestion, discardEmailSuggestionBox diff --git a/config/initializers/active_job_log_subscriber.rb b/app/lib/active_job/application_log_subscriber.rb similarity index 95% rename from config/initializers/active_job_log_subscriber.rb rename to app/lib/active_job/application_log_subscriber.rb index 854fc5a61..1f7a3361a 100644 --- a/config/initializers/active_job_log_subscriber.rb +++ b/app/lib/active_job/application_log_subscriber.rb @@ -1,7 +1,7 @@ require 'active_job/logging' require 'logstash-event' -class ActiveJobLogSubscriber < ::ActiveJob::LogSubscriber +class ActiveJob::ApplicationLogSubscriber < ::ActiveJob::LogSubscriber def enqueue(event) process_event(event, 'enqueue') end diff --git a/app/lib/api_particulier/cnaf_adapter.rb b/app/lib/api_particulier/cnaf_adapter.rb index 7a2412d82..5377fecaf 100644 --- a/app/lib/api_particulier/cnaf_adapter.rb +++ b/app/lib/api_particulier/cnaf_adapter.rb @@ -1,4 +1,4 @@ -class APIParticulier::CNAFAdapter +class APIParticulier::CnafAdapter class InvalidSchemaError < ::StandardError def initialize(errors) super(errors.map(&:to_json).join("\n")) diff --git a/app/lib/api_particulier/error.rb b/app/lib/api_particulier/error.rb index acc810451..4224bcad7 100644 --- a/app/lib/api_particulier/error.rb +++ b/app/lib/api_particulier/error.rb @@ -14,7 +14,7 @@ module APIParticulier msg = <<~TEXT url: #{url} HTTP error code: #{http_error_code} - #{response.body} + #{response.body.force_encoding('UTF-8')} curl message: #{curl_message} total time: #{total_time} connect time: #{connect_time} diff --git a/app/lib/api_particulier/services/sources_service.rb b/app/lib/api_particulier/services/sources_service.rb index 26971ae32..4d66c32d0 100644 --- a/app/lib/api_particulier/services/sources_service.rb +++ b/app/lib/api_particulier/services/sources_service.rb @@ -8,6 +8,7 @@ module APIParticulier def available_sources @procedure.api_particulier_scopes .map { |provider_and_scope| raw_scopes[provider_and_scope] } + .compact .map { |provider, scope| extract_sources(provider, scope) } .reduce({}) { |acc, el| acc.deep_merge(el) } end diff --git a/app/models/champ.rb b/app/models/champ.rb index 539c5af8b..61930b3ab 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/address_champ.rb b/app/models/champs/address_champ.rb index 72da63012..3e7870b50 100644 --- a/app/models/champs/address_champ.rb +++ b/app/models/champs/address_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/annuaire_education_champ.rb b/app/models/champs/annuaire_education_champ.rb index d5a895224..22141ca17 100644 --- a/app/models/champs/annuaire_education_champ.rb +++ b/app/models/champs/annuaire_education_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/carte_champ.rb b/app/models/champs/carte_champ.rb index f8b67c82c..1e91e529e 100644 --- a/app/models/champs/carte_champ.rb +++ b/app/models/champs/carte_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/checkbox_champ.rb b/app/models/champs/checkbox_champ.rb index 40b38af23..82f009577 100644 --- a/app/models/champs/checkbox_champ.rb +++ b/app/models/champs/checkbox_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/civilite_champ.rb b/app/models/champs/civilite_champ.rb index 8f04758cb..ace32a968 100644 --- a/app/models/champs/civilite_champ.rb +++ b/app/models/champs/civilite_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/cnaf_champ.rb b/app/models/champs/cnaf_champ.rb new file mode 100644 index 000000000..fc3c10744 --- /dev/null +++ b/app/models/champs/cnaf_champ.rb @@ -0,0 +1,52 @@ +# == Schema Information +# +# Table name: champs +# +# id :integer not null, primary key +# data :jsonb +# fetch_external_data_exceptions :string is an Array +# private :boolean default(FALSE), not null +# row :integer +# type :string +# value :string +# value_json :jsonb +# created_at :datetime +# updated_at :datetime +# dossier_id :integer +# etablissement_id :integer +# external_id :string +# parent_id :bigint +# type_de_champ_id :integer +# +class Champs::CnafChamp < Champs::TextChamp + # see https://github.com/betagouv/api-particulier/blob/master/src/presentation/middlewares/cnaf-input-validation.middleware.ts + validates :numero_allocataire, format: { with: /\A\d{1,7}\z/ }, if: -> { code_postal.present? && validation_context != :brouillon } + validates :code_postal, format: { with: /\A\w{5}\z/ }, if: -> { numero_allocataire.present? && validation_context != :brouillon } + + store_accessor :value_json, :numero_allocataire, :code_postal + + def blank? + external_id.nil? + end + + def fetch_external_data? + true + end + + def fetch_external_data + if valid? + APIParticulier::CnafAdapter.new( + procedure.api_particulier_token, + numero_allocataire, + code_postal, + procedure.api_particulier_sources + ).to_params + end + end + + def external_id + if numero_allocataire.present? && code_postal.present? + { code_postal: code_postal, numero_allocataire: numero_allocataire }.to_json + end + end +end diff --git a/app/models/champs/commune_champ.rb b/app/models/champs/commune_champ.rb index 34569584f..349221cf1 100644 --- a/app/models/champs/commune_champ.rb +++ b/app/models/champs/commune_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/date_champ.rb b/app/models/champs/date_champ.rb index 1eca35bf8..2e09bd29d 100644 --- a/app/models/champs/date_champ.rb +++ b/app/models/champs/date_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/datetime_champ.rb b/app/models/champs/datetime_champ.rb index cf2d6c7e1..f7f5bd496 100644 --- a/app/models/champs/datetime_champ.rb +++ b/app/models/champs/datetime_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/decimal_number_champ.rb b/app/models/champs/decimal_number_champ.rb index b907c1c50..f574c07ca 100644 --- a/app/models/champs/decimal_number_champ.rb +++ b/app/models/champs/decimal_number_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/departement_champ.rb b/app/models/champs/departement_champ.rb index d4eaa8501..ac5c89633 100644 --- a/app/models/champs/departement_champ.rb +++ b/app/models/champs/departement_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/dossier_link_champ.rb b/app/models/champs/dossier_link_champ.rb index 39ba322f8..866f70197 100644 --- a/app/models/champs/dossier_link_champ.rb +++ b/app/models/champs/dossier_link_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/drop_down_list_champ.rb b/app/models/champs/drop_down_list_champ.rb index 29b9be33a..92f52e3f1 100644 --- a/app/models/champs/drop_down_list_champ.rb +++ b/app/models/champs/drop_down_list_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/email_champ.rb b/app/models/champs/email_champ.rb index 67bdd1ca4..c8a1949b0 100644 --- a/app/models/champs/email_champ.rb +++ b/app/models/champs/email_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/engagement_champ.rb b/app/models/champs/engagement_champ.rb index b6992749e..986623136 100644 --- a/app/models/champs/engagement_champ.rb +++ b/app/models/champs/engagement_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/explication_champ.rb b/app/models/champs/explication_champ.rb index 022149978..d20108393 100644 --- a/app/models/champs/explication_champ.rb +++ b/app/models/champs/explication_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/header_section_champ.rb b/app/models/champs/header_section_champ.rb index 983e60b04..0ff9c7659 100644 --- a/app/models/champs/header_section_champ.rb +++ b/app/models/champs/header_section_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/iban_champ.rb b/app/models/champs/iban_champ.rb index feb3429dd..71ba23281 100644 --- a/app/models/champs/iban_champ.rb +++ b/app/models/champs/iban_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/integer_number_champ.rb b/app/models/champs/integer_number_champ.rb index 68bf2e173..8272800e1 100644 --- a/app/models/champs/integer_number_champ.rb +++ b/app/models/champs/integer_number_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/linked_drop_down_list_champ.rb b/app/models/champs/linked_drop_down_list_champ.rb index 157f4af89..f7022d727 100644 --- a/app/models/champs/linked_drop_down_list_champ.rb +++ b/app/models/champs/linked_drop_down_list_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/multiple_drop_down_list_champ.rb b/app/models/champs/multiple_drop_down_list_champ.rb index 5a239151c..87429b0ee 100644 --- a/app/models/champs/multiple_drop_down_list_champ.rb +++ b/app/models/champs/multiple_drop_down_list_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/number_champ.rb b/app/models/champs/number_champ.rb index e0186615c..fa2ec9b47 100644 --- a/app/models/champs/number_champ.rb +++ b/app/models/champs/number_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/pays_champ.rb b/app/models/champs/pays_champ.rb index bddc575ef..634391a31 100644 --- a/app/models/champs/pays_champ.rb +++ b/app/models/champs/pays_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/phone_champ.rb b/app/models/champs/phone_champ.rb index da1166e6b..15002fe60 100644 --- a/app/models/champs/phone_champ.rb +++ b/app/models/champs/phone_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/piece_justificative_champ.rb b/app/models/champs/piece_justificative_champ.rb index 862c57b67..245750c01 100644 --- a/app/models/champs/piece_justificative_champ.rb +++ b/app/models/champs/piece_justificative_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/region_champ.rb b/app/models/champs/region_champ.rb index cf5e89075..255e4abb2 100644 --- a/app/models/champs/region_champ.rb +++ b/app/models/champs/region_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/repetition_champ.rb b/app/models/champs/repetition_champ.rb index 7e9ac54aa..b07fd0958 100644 --- a/app/models/champs/repetition_champ.rb +++ b/app/models/champs/repetition_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/siret_champ.rb b/app/models/champs/siret_champ.rb index 2e21f1ff3..b9b5b27b5 100644 --- a/app/models/champs/siret_champ.rb +++ b/app/models/champs/siret_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/text_champ.rb b/app/models/champs/text_champ.rb index 11fcb3939..1376cce48 100644 --- a/app/models/champs/text_champ.rb +++ b/app/models/champs/text_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/textarea_champ.rb b/app/models/champs/textarea_champ.rb index 89177feb2..028950a5f 100644 --- a/app/models/champs/textarea_champ.rb +++ b/app/models/champs/textarea_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/titre_identite_champ.rb b/app/models/champs/titre_identite_champ.rb index 289546476..982ae7ae7 100644 --- a/app/models/champs/titre_identite_champ.rb +++ b/app/models/champs/titre_identite_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/champs/yes_no_champ.rb b/app/models/champs/yes_no_champ.rb index ccbd46186..fc8346cfb 100644 --- a/app/models/champs/yes_no_champ.rb +++ b/app/models/champs/yes_no_champ.rb @@ -9,6 +9,7 @@ # row :integer # type :string # value :string +# value_json :jsonb # created_at :datetime # updated_at :datetime # dossier_id :integer diff --git a/app/models/expert.rb b/app/models/expert.rb index 01042c840..4574a474f 100644 --- a/app/models/expert.rb +++ b/app/models/expert.rb @@ -38,6 +38,8 @@ class Expert < ApplicationRecord end def merge(old_expert) + return if old_expert.nil? + procedure_with_new, procedure_without_new = old_expert .procedures .partition { |p| p.experts.exists?(id) } diff --git a/app/models/france_connect_information.rb b/app/models/france_connect_information.rb index d76ee60d8..06180a43a 100644 --- a/app/models/france_connect_information.rb +++ b/app/models/france_connect_information.rb @@ -10,35 +10,50 @@ # family_name :string # gender :string # given_name :string +# merge_token :string +# merge_token_created_at :datetime # created_at :datetime not null # updated_at :datetime not null # france_connect_particulier_id :string -# user_id :integer not null +# user_id :integer # class FranceConnectInformation < ApplicationRecord + MERGE_VALIDITY = 15.minutes + belongs_to :user, optional: true validates :france_connect_particulier_id, presence: true, allow_blank: false, allow_nil: false - def associate_user! - user = User.find_by(email: email_france_connect.downcase) - - if user.nil? - begin - user = User.create!( - email: email_france_connect.downcase, - password: Devise.friendly_token[0, 20], - confirmed_at: Time.zone.now - ) - rescue ActiveRecord::RecordNotUnique - # ignore this exception because we check before is user is nil. - # exception can be raised in race conditions, when FranceConnect calls callback 2 times. - # At the 2nd call, user is nil but exception is raised at the creation of the user - # because the first call has already created a user - end + def associate_user!(email) + begin + user = User.create!( + email: email.downcase, + password: Devise.friendly_token[0, 20], + confirmed_at: Time.zone.now + ) + rescue ActiveRecord::RecordNotUnique + # ignore this exception because we check before is user is nil. + # exception can be raised in race conditions, when FranceConnect calls callback 2 times. + # At the 2nd call, user is nil but exception is raised at the creation of the user + # because the first call has already created a user end update_attribute('user_id', user.id) touch # needed to update updated_at column end + + def create_merge_token! + merge_token = SecureRandom.uuid + update(merge_token: merge_token, merge_token_created_at: Time.zone.now) + + merge_token + end + + def valid_for_merge? + (MERGE_VALIDITY.ago < merge_token_created_at) && user_id.nil? + end + + def delete_merge_token! + update(merge_token: nil, merge_token_created_at: nil) + end end diff --git a/app/models/instructeur.rb b/app/models/instructeur.rb index e9b4c5985..0a3da5b33 100644 --- a/app/models/instructeur.rb +++ b/app/models/instructeur.rb @@ -252,6 +252,8 @@ class Instructeur < ApplicationRecord end def merge(old_instructeur) + return if old_instructeur.nil? + old_instructeur .assign_to .where.not(groupe_instructeur_id: assign_to.pluck(:groupe_instructeur_id)) diff --git a/app/models/procedure.rb b/app/models/procedure.rb index e74f8f47d..df9aba673 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -716,6 +716,10 @@ class Procedure < ApplicationRecord published_revision.touch(:published_at) end + def cnaf_enabled? + api_particulier_sources['cnaf'].present? + end + private def before_publish diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 2976b6e7d..dcf5b096d 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -48,7 +48,8 @@ class TypeDeChamp < ApplicationRecord repetition: 'repetition', titre_identite: 'titre_identite', iban: 'iban', - annuaire_education: 'annuaire_education' + annuaire_education: 'annuaire_education', + cnaf: 'cnaf' } belongs_to :revision, class_name: 'ProcedureRevision', optional: true @@ -291,17 +292,26 @@ class TypeDeChamp < ApplicationRecord def self.type_de_champ_types_for(procedure, user) has_legacy_number = (procedure.types_de_champ + procedure.types_de_champ_private).any?(&:legacy_number?) - show_number = -> (tdc) { tdc != TypeDeChamp.type_champs.fetch(:number) || has_legacy_number } - - enabled_featured_champ = -> (tdc) do + filter_featured_tdc = -> (tdc) do feature_name = FEATURE_FLAGS[tdc] feature_name.blank? || Flipper.enabled?(feature_name, user) end + filter_tdc = -> (tdc) do + case tdc + when TypeDeChamp.type_champs.fetch(:number) + has_legacy_number + when TypeDeChamp.type_champs.fetch(:cnaf) + procedure.cnaf_enabled? + else + true + end + end + type_champs .keys - .filter(&show_number) - .filter(&enabled_featured_champ) + .filter(&filter_tdc) + .filter(&filter_featured_tdc) .map { |tdc| [I18n.t("activerecord.attributes.type_de_champ.type_champs.#{tdc}"), tdc] } .sort_by(&:first) end diff --git a/app/models/types_de_champ/cnaf_type_de_champ.rb b/app/models/types_de_champ/cnaf_type_de_champ.rb new file mode 100644 index 000000000..2702bec84 --- /dev/null +++ b/app/models/types_de_champ/cnaf_type_de_champ.rb @@ -0,0 +1,2 @@ +class TypesDeChamp::CnafTypeDeChamp < TypesDeChamp::TextTypeDeChamp +end diff --git a/app/views/france_connect/particulier/_password_confirmation.html.haml b/app/views/france_connect/particulier/_password_confirmation.html.haml new file mode 100644 index 000000000..0bc209dff --- /dev/null +++ b/app/views/france_connect/particulier/_password_confirmation.html.haml @@ -0,0 +1,12 @@ +%p + Le compte #{email} existe déjà sur #{APPLICATION_NAME} + %br + entrez votre mot de passe pour fusionner les comptes + += form_tag france_connect_particulier_merge_with_existing_account_path, remote: true, class: 'mt-2 form' do + = hidden_field_tag :merge_token, merge_token + = hidden_field_tag :email, email + = label_tag :password, 'Mot de passe (8 caractères minimum)' + = password_field_tag :password, nil, autocomplete: 'current-password', id: 'password-for-another-account' + = button_tag 'revenir en arrière', type: 'button', class: 'button secondary', onclick: 'DS.showNewAccount(event);' + = submit_tag 'Fusionner les comptes', class: 'button primary' diff --git a/app/views/france_connect/particulier/merge.html.haml b/app/views/france_connect/particulier/merge.html.haml new file mode 100644 index 000000000..c8f4ae656 --- /dev/null +++ b/app/views/france_connect/particulier/merge.html.haml @@ -0,0 +1,44 @@ += content_for :title, "Fusion des comptes FC et #{APPLICATION_NAME}" + +.container + %h1.page-title Fusion des comptes FranceConnect et #{APPLICATION_NAME} + + %p + Bonjour, + %br + %br + Votre compte FranceConnect utilise #{@fci.email_france_connect} comme email de contact. + %br + Or il existe un compte sur #{APPLICATION_NAME} avec cet email. + + .form.mt-2 + %label Ce compte #{@fci.email_france_connect} vous appartient-il ? + %fieldset.radios + %label{ onclick: "DS.showFusion(event);" } + = radio_button_tag :value, true, false, autocomplete: "off", id: 'it-is-mine' + Oui + + %label{ onclick: "DS.showNewAccount(event);" } + = radio_button_tag :value, false, false, autocomplete: "off", id: 'it-is-not-mine' + Non + + .fusion.hidden + %p Pour les fusionner, entrez votre mot de passe + + = form_tag france_connect_particulier_merge_with_existing_account_path, remote: true, class: 'mt-2 form' do + = hidden_field_tag :merge_token, @fci.merge_token + = hidden_field_tag :email, @fci.email_france_connect + = label_tag :password, 'Mot de passe (8 caractères minimum)' + = password_field_tag :password, nil, autocomplete: 'current-password' + = submit_tag 'Fusionner les comptes', class: 'button primary' + + .new-account.hidden + %p Donnez-nous alors le mail que #{APPLICATION_NAME} utilisera pour vous contacter + + = form_tag france_connect_particulier_merge_with_new_account_path, remote: true, class: 'mt-2 form' do + = hidden_field_tag :merge_token, @fci.merge_token + = label_tag :email, 'Email (nom@site.com)' + = email_field_tag :email + = submit_tag 'Utiliser ce mail', class: 'button primary' + + .new-account-password-confirmation.hidden diff --git a/app/views/france_connect/particulier/merge_with_new_account.js.erb b/app/views/france_connect/particulier/merge_with_new_account.js.erb new file mode 100644 index 000000000..cea1ca67a --- /dev/null +++ b/app/views/france_connect/particulier/merge_with_new_account.js.erb @@ -0,0 +1,3 @@ +<%= render_to_element('.new-account-password-confirmation', partial: 'password_confirmation', locals: { email: @email, merge_token: @merge_token }) %> + +DS.showNewAccountPasswordConfirmation(); diff --git a/app/views/new_administrateur/jeton_particulier/api_particulier.html.haml b/app/views/new_administrateur/jeton_particulier/api_particulier.html.haml index ad8f7bc14..40f23fa2c 100644 --- a/app/views/new_administrateur/jeton_particulier/api_particulier.html.haml +++ b/app/views/new_administrateur/jeton_particulier/api_particulier.html.haml @@ -5,7 +5,7 @@ .container .flex - = link_to admin_procedure_api_particulier_jeton_path, class: 'card-admin' do + = link_to admin_procedure_api_particulier_jeton_path, class: 'card-admin', id: 'add-jeton' do - if @procedure.api_particulier_token.blank? %div %span.icon.clock diff --git a/app/views/new_administrateur/procedures/_publication_form.html.haml b/app/views/new_administrateur/procedures/_publication_form.html.haml index 1af036c6a..0989ab17b 100644 --- a/app/views/new_administrateur/procedures/_publication_form.html.haml +++ b/app/views/new_administrateur/procedures/_publication_form.html.haml @@ -40,4 +40,4 @@ - if errors.details[:path].present? - options[:disabled] = :disabled .flex.justify-end - = submit_tag procedure_publish_text(procedure, :submit), options + = submit_tag procedure_publish_label(procedure, :submit), options diff --git a/app/views/new_administrateur/procedures/show.html.haml b/app/views/new_administrateur/procedures/show.html.haml index d0aa14694..40d26f61e 100644 --- a/app/views/new_administrateur/procedures/show.html.haml +++ b/app/views/new_administrateur/procedures/show.html.haml @@ -193,7 +193,7 @@ %p.button Modifier - if feature_enabled?(:api_particulier) - = link_to admin_procedure_api_particulier_path(@procedure), class: 'card-admin' do + = link_to admin_procedure_api_particulier_path(@procedure), class: 'card-admin', id: 'api-particulier' do - if @procedure.api_particulier_token.present? %div %span.icon.accept diff --git a/app/views/new_administrateur/sources_particulier/show.html.haml b/app/views/new_administrateur/sources_particulier/show.html.haml index cd06d96c6..6f839d482 100644 --- a/app/views/new_administrateur/sources_particulier/show.html.haml +++ b/app/views/new_administrateur/sources_particulier/show.html.haml @@ -16,7 +16,7 @@ - scopes.each do |scope_key, sources| %h3.explication-libelle= t("api_particulier.providers.#{provider_key}.scopes.#{scope_key}.libelle") - %ul.procedure-admin-api-particulier-sources + %ul.procedure-admin-api-particulier-sources{ id: scope_key } - sources.each do |source_key, enabled_hash| - enabled = (@procedure.api_particulier_sources.dig(provider_key, scope_key)&.include?(source_key)).present? %li diff --git a/app/views/shared/champs/cnaf/_adresse.html.haml b/app/views/shared/champs/cnaf/_adresse.html.haml new file mode 100644 index 000000000..9c3cbdd1e --- /dev/null +++ b/app/views/shared/champs/cnaf/_adresse.html.haml @@ -0,0 +1,10 @@ +%table.cnaf + %caption #{t("api_particulier.providers.cnaf.scopes.adresse.libelle")} : + - for key in ['identite', 'complementIdentite', 'numeroRue', 'complementIdentiteGeo', 'lieuDit', 'codePostalVille', 'pays'] do + - if adresse[key].present? + %tr + %th= t("api_particulier.providers.cnaf.scopes.adresse.#{key}") + %td= adresse[key] + + + diff --git a/app/views/shared/champs/cnaf/_personnes.html.haml b/app/views/shared/champs/cnaf/_personnes.html.haml new file mode 100644 index 000000000..d4fa3ee95 --- /dev/null +++ b/app/views/shared/champs/cnaf/_personnes.html.haml @@ -0,0 +1,19 @@ +%table.cnaf.horizontal + %caption #{t("api_particulier.providers.cnaf.scopes.#{scope}.libelle")} : + %thead + %tr + - for key in ['nomPrenom', 'sexe', 'dateDeNaissance'] do + - if personnes.first[key].present? + %th{ class: "#{"text-right" if key == 'dateDeNaissance'}" }= t("api_particulier.providers.cnaf.scopes.personne.#{key}") + %tbody + - personnes.each do |personne| + %tr + - for key in ['nomPrenom', 'sexe', 'dateDeNaissance'] do + - if personne[key].present? + - case key + - when 'dateDeNaissance' + %td.text-right= try_format_datetime(Date.strptime(personne[key], "%d%m%Y")) + - when 'sexe' + %td= t("api_particulier.providers.cnaf.scopes.personne.#{personne[key]}") + - else + %td= personne[key] diff --git a/app/views/shared/champs/cnaf/_quotient_familial.html.haml b/app/views/shared/champs/cnaf/_quotient_familial.html.haml new file mode 100644 index 000000000..b01d09ee7 --- /dev/null +++ b/app/views/shared/champs/cnaf/_quotient_familial.html.haml @@ -0,0 +1,14 @@ +%table.cnaf.horizontal + %caption #{t("api_particulier.providers.cnaf.scopes.quotient_familial.libelle")} : + %thead + %tr + - for key in ['quotientFamilial', 'mois', 'annee'] do + - if quotient_familial[key].present? + %th.text-right= t("api_particulier.providers.cnaf.scopes.quotient_familial.#{key}") + %tbody + %tr + - for key in ['quotientFamilial', 'mois', 'annee'] do + - if quotient_familial[key].present? + %td.text-right= quotient_familial[key] + - else + %td diff --git a/app/views/shared/champs/cnaf/_show.html.haml b/app/views/shared/champs/cnaf/_show.html.haml new file mode 100644 index 000000000..82a7333c6 --- /dev/null +++ b/app/views/shared/champs/cnaf/_show.html.haml @@ -0,0 +1,24 @@ +- if champ.blank? + %p= t('.not_filled') +- elsif champ.data.blank? + %p= t('.fetching_data', + numero_allocataire: champ.numero_allocataire, + code_postal: champ.code_postal) +- else + - if profile == 'usager' + %p= t('.data_fetched', + sources: champ.procedure.api_particulier_sources['cnaf'].keys.map(&:to_s).join(', '), + numero_allocataire: champ.numero_allocataire, + code_postal: champ.code_postal) + + - if profile == 'instructeur' + %p= t('.data_fetched_title') + + - ['adresse', 'quotient_familial', 'enfants', 'allocataires'].each do |scope| + - if champ.data[scope].present? + - if scope == 'quotient_familial' + = render partial: 'shared/champs/cnaf/quotient_familial', locals: { quotient_familial: champ.data[scope] } + - if scope.in? ['enfants', 'allocataires'] + = render partial: 'shared/champs/cnaf/personnes', locals: { scope: scope, personnes: champ.data[scope] } + - elsif scope == 'adresse' + = render partial: 'shared/champs/cnaf/adresse', locals: { adresse: champ.data[scope] } diff --git a/app/views/shared/dossiers/_champ_row.html.haml b/app/views/shared/dossiers/_champ_row.html.haml index 9f4057894..0264327cf 100644 --- a/app/views/shared/dossiers/_champ_row.html.haml +++ b/app/views/shared/dossiers/_champ_row.html.haml @@ -36,6 +36,8 @@ = render partial: "shared/champs/textarea/show", locals: { champ: c } - when TypeDeChamp.type_champs.fetch(:annuaire_education) = render partial: "shared/champs/annuaire_education/show", locals: { champ: c } + - when TypeDeChamp.type_champs.fetch(:cnaf) + = render partial: "shared/champs/cnaf/show", locals: { champ: c, profile: profile } - when TypeDeChamp.type_champs.fetch(:address) = render partial: "shared/champs/address/show", locals: { champ: c } - when TypeDeChamp.type_champs.fetch(:communes) diff --git a/app/views/shared/dossiers/_france_connect_informations.html.haml b/app/views/shared/dossiers/_france_connect_informations.html.haml index b875f26b4..bd73c94e9 100644 --- a/app/views/shared/dossiers/_france_connect_informations.html.haml +++ b/app/views/shared/dossiers/_france_connect_informations.html.haml @@ -1,7 +1,7 @@ -.card.featured - .flex.justify-center - = image_tag "logo-france-connect.png", alt: "France Connect logo", width: 200, class: "mb-2" - .card-title +.france-connect-informations.card.featured.flex.align-center + .france-connect-informations-logo + = image_tag "logo-france-connect.png", alt: "France Connect logo" + %div - if user_information.updated_at.present? Le dossier a été déposé par le compte de #{user_information&.given_name} #{user_information&.family_name}, authentifié par France Connect le #{user_information.updated_at.strftime('%d/%m/%Y')}. - else diff --git a/app/views/shared/dossiers/editable_champs/_cnaf.html.haml b/app/views/shared/dossiers/editable_champs/_cnaf.html.haml new file mode 100644 index 000000000..1d8d90610 --- /dev/null +++ b/app/views/shared/dossiers/editable_champs/_cnaf.html.haml @@ -0,0 +1,16 @@ +.cnaf-inputs + %div + = form.label :numero_allocataire, t('.numero_allocataire_label') + %p.notice= t('.numero_allocataire_notice') + = form.text_field :numero_allocataire, + required: champ.mandatory?, + size: 7, + aria: { describedby: describedby_id(champ) } + + %div + = form.label :code_postal, t('.code_postal_label') + %p.notice= t('.code_postal_notice') + = form.text_field :code_postal, + size: 5, + required: champ.mandatory?, + aria: { describedby: describedby_id(champ) } diff --git a/app/views/shared/dossiers/editable_champs/_engagement.html.haml b/app/views/shared/dossiers/editable_champs/_engagement.html.haml index 5bd197a73..707b2adcd 100644 --- a/app/views/shared/dossiers/editable_champs/_engagement.html.haml +++ b/app/views/shared/dossiers/editable_champs/_engagement.html.haml @@ -2,5 +2,3 @@ { required: champ.mandatory? }, 'on', 'off' - -%br diff --git a/app/views/shared/dossiers/editable_champs/_yes_no.html.haml b/app/views/shared/dossiers/editable_champs/_yes_no.html.haml index 11cd71ad0..6a6099dda 100644 --- a/app/views/shared/dossiers/editable_champs/_yes_no.html.haml +++ b/app/views/shared/dossiers/editable_champs/_yes_no.html.haml @@ -1,6 +1,4 @@ %fieldset.radios - %legend.mandatory-explanation - Sélectionnez une des deux valeurs %label = form.radio_button :value, true Oui diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 1631f421b..c1ecd861e 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -14,7 +14,6 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.acronym 'JSON' inflect.acronym 'RNA' inflect.acronym 'URL' - inflect.acronym 'CNAF' inflect.irregular 'type_de_champ', 'types_de_champ' inflect.irregular 'type_de_champ_private', 'types_de_champ_private' inflect.irregular 'procedure_revision_type_de_champ', 'procedure_revision_types_de_champ' diff --git a/config/initializers/lograge.rb b/config/initializers/lograge.rb index 5a63f1051..6be2a946b 100644 --- a/config/initializers/lograge.rb +++ b/config/initializers/lograge.rb @@ -1,5 +1,3 @@ -require_relative './active_job_log_subscriber' - Rails.application.configure do config.lograge.formatter = Lograge::Formatters::Logstash.new config.lograge.base_controller_class = ['ActionController::Base', 'Manager::ApplicationController'] @@ -33,6 +31,6 @@ Rails.application.configure do config.lograge.logger = ActiveSupport::Logger.new(Rails.root.join('log', "logstash_#{Rails.env}.log")) if config.lograge.enabled - ActiveJobLogSubscriber.attach_to(:active_job) + ActiveJob::ApplicationLogSubscriber.attach_to(:active_job) end end diff --git a/config/locales/api_particulier.en.yml b/config/locales/api_particulier.en.yml new file mode 100644 index 000000000..6c0abf1b5 --- /dev/null +++ b/config/locales/api_particulier.en.yml @@ -0,0 +1,32 @@ +en: + api_particulier: + providers: + cnaf: + libelle: Caisse nationale d’allocations familiales (CAF) + scopes: + personne: &personne + nomPrenom: first and last name + dateDeNaissance: birth date + sexe: sex + M: male + F: female + allocataires: + libelle: beneficiaries + <<: *personne + enfants: + libelle: children + <<: *personne + adresse: + libelle: address + identite: identity + complementIdentite: complément d’identité + complementIdentiteGeo: complément d’identité géographique + numeroRue: number and street + lieuDit: lieu-dit + codePostalVille: postcode and city + pays: country + quotient_familial: + libelle: quotient familial + quotientFamilial: quotient familial + mois: month + annee: year diff --git a/config/locales/api_particulier.fr.yml b/config/locales/api_particulier.fr.yml index 39841906f..352250e17 100644 --- a/config/locales/api_particulier.fr.yml +++ b/config/locales/api_particulier.fr.yml @@ -2,12 +2,14 @@ fr: api_particulier: providers: cnaf: - libelle: Caisse d’allocations familiales (CAF) + libelle: Caisse nationale d’allocations familiales (CAF) scopes: personne: &personne nomPrenom: noms et prénoms dateDeNaissance: date de naissance - sexe: genre + sexe: sexe + M: masculin + F: féminin allocataires: libelle: allocataires <<: *personne diff --git a/config/locales/en.yml b/config/locales/en.yml index 0224de480..5c6bccab0 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -274,6 +274,12 @@ en: taken: is already used for procedure. You cannot use it because it belongs to another administrator. # taken_can_be_claimed: est identique à celui d’une autre de vos procedures publiées. Si vous publiez cette procedure, l’ancienne sera dépubliée et ne sera plus accessible au public. Les utilisateurs qui ont commencé un brouillon vont pouvoir le déposer. invalid: is not valid. It must countain between 3 and 50 characters among a-z, 0-9, '_' and '-'. + "champs/cnaf_champ": + attributes: + numero_allocataire: + invalid: "must be a maximum of 7 digits" + code_postal: + invalid: "must be 5 characters long" errors: messages: dossier_not_found: "The file does not exist or you do not have access to it." diff --git a/config/locales/fr.yml b/config/locales/fr.yml index cf9eef404..d2361db5a 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -280,6 +280,12 @@ fr: taken: est déjà utilisé par une démarche. Vous ne pouvez pas l’utiliser car il appartient à un autre administrateur. taken_can_be_claimed: est identique à celui d’une autre de vos démarches publiées. Si vous publiez cette démarche, l’ancienne sera dépubliée et ne sera plus accessible au public. Les utilisateurs qui ont commencé un brouillon vont pouvoir le déposer. invalid: n’est pas valide. Il doit comporter au moins 3 caractères, au plus 50 caractères et seuls les caractères a-z, 0-9, '_' et '-' sont autorisés. + "champs/cnaf_champ": + attributes: + numero_allocataire: + invalid: "doit être composé au maximum de 7 chiffres" + code_postal: + invalid: "doit posséder 5 caractères" errors: messages: saml_not_authorized: "Vous n’êtes pas autorisé à accéder à ce service." diff --git a/config/locales/models/champs/fr.yml b/config/locales/models/champs/fr.yml deleted file mode 100644 index 7a0730a32..000000000 --- a/config/locales/models/champs/fr.yml +++ /dev/null @@ -1,6 +0,0 @@ -fr: - activerecord: - attributes: - champ: - value: La valeur du champ - piece_justificative_file: La pièce justificative diff --git a/config/locales/models/type_de_champ/fr.yml b/config/locales/models/type_de_champ/fr.yml index 9d42446f4..15a4d2aad 100644 --- a/config/locales/models/type_de_champ/fr.yml +++ b/config/locales/models/type_de_champ/fr.yml @@ -36,3 +36,5 @@ fr: titre_identite: 'Titre identité' iban: 'Iban' annuaire_education: 'Annuaire de l’éducation' + cnaf: 'Données de la Caisse nationale des allocations familiales' + diff --git a/config/locales/shared.en.yml b/config/locales/shared.en.yml new file mode 100644 index 000000000..7ef3d6294 --- /dev/null +++ b/config/locales/shared.en.yml @@ -0,0 +1,16 @@ +en: + shared: + dossiers: + editable_champs: + cnaf: + numero_allocataire_label: CAF benefit number + numero_allocataire_notice: It is usually composed of 7 digits. + code_postal_label: postal code + code_postal_notice: It is usually composed of 5 digits. + champs: + cnaf: + show: + not_filled: not filled + fetching_data: "Fetching data for recipient No. %{numero_allocataire} with postal code %{code_postal}." + data_fetched: "Data concerning %{sources} linked to the account Nº %{numero_allocataire} with the postal code %{code_postal} has been received from the CAF." + data_fetched_title: "Data received from la Caisse nationale d’allocations familiales" diff --git a/config/locales/shared.fr.yml b/config/locales/shared.fr.yml new file mode 100644 index 000000000..a0bf159e1 --- /dev/null +++ b/config/locales/shared.fr.yml @@ -0,0 +1,16 @@ +fr: + shared: + dossiers: + editable_champs: + cnaf: + numero_allocataire_label: Le numéro d’allocataire CAF + numero_allocataire_notice: Il est généralement composé de 7 chiffres. + code_postal_label: Le code postal + code_postal_notice: Il est généralement composé de 5 chiffres. + champs: + cnaf: + show: + not_filled: non renseigné + fetching_data: "La récupération automatique des données pour l’allocataire Nº %{numero_allocataire} avec le code postal %{code_postal} est en cours." + data_fetched: "Des données concernant %{sources} liées au compte Nº %{numero_allocataire} avec le code postal %{code_postal} ont été reçues depuis la CAF." + data_fetched_title: "Données obtenues de la Caisse nationale d’allocations familiales" diff --git a/config/routes.rb b/config/routes.rb index 3e8b1168b..38bb0241d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -124,6 +124,9 @@ Rails.application.routes.draw do namespace :france_connect do get 'particulier' => 'particulier#login' get 'particulier/callback' => 'particulier#callback' + get 'particulier/merge/:merge_token' => 'particulier#merge', as: :particulier_merge + post 'particulier/merge_with_existing_account' => 'particulier#merge_with_existing_account' + post 'particulier/merge_with_new_account' => 'particulier#merge_with_new_account' end namespace :champs do diff --git a/db/migrate/20211005133027_add_value_json_column_to_champ.rb b/db/migrate/20211005133027_add_value_json_column_to_champ.rb new file mode 100644 index 000000000..92b9d7a6e --- /dev/null +++ b/db/migrate/20211005133027_add_value_json_column_to_champ.rb @@ -0,0 +1,5 @@ +class AddValueJSONColumnToChamp < ActiveRecord::Migration[6.1] + def change + add_column :champs, :value_json, :jsonb + end +end diff --git a/db/migrate/20211011083203_remove_user_id_not_null_constraint_to_france_connect_information.rb b/db/migrate/20211011083203_remove_user_id_not_null_constraint_to_france_connect_information.rb new file mode 100644 index 000000000..212352604 --- /dev/null +++ b/db/migrate/20211011083203_remove_user_id_not_null_constraint_to_france_connect_information.rb @@ -0,0 +1,5 @@ +class RemoveUserIdNotNullConstraintToFranceConnectInformation < ActiveRecord::Migration[6.1] + def change + change_column_null(:france_connect_informations, :user_id, true) + end +end diff --git a/db/migrate/20211011102957_add_token_columns_to_france_connect_information.rb b/db/migrate/20211011102957_add_token_columns_to_france_connect_information.rb new file mode 100644 index 000000000..1a8b9ee73 --- /dev/null +++ b/db/migrate/20211011102957_add_token_columns_to_france_connect_information.rb @@ -0,0 +1,8 @@ +class AddTokenColumnsToFranceConnectInformation < ActiveRecord::Migration[6.1] + def change + add_column :france_connect_informations, :merge_token, :string + add_column :france_connect_informations, :merge_token_created_at, :datetime + + add_index :france_connect_informations, :merge_token + end +end diff --git a/db/migrate/20211012100819_add_foreign_key_to_avis_dossier_id.rb b/db/migrate/20211012100819_add_foreign_key_to_avis_dossier_id.rb new file mode 100644 index 000000000..aab456696 --- /dev/null +++ b/db/migrate/20211012100819_add_foreign_key_to_avis_dossier_id.rb @@ -0,0 +1,5 @@ +class AddForeignKeyToAvisDossierId < ActiveRecord::Migration[6.1] + def change + add_foreign_key :avis, :dossiers + end +end diff --git a/db/schema.rb b/db/schema.rb index 2d29640fd..219a263a8 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_10_06_164955) do +ActiveRecord::Schema.define(version: 2021_10_12_100819) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -189,6 +189,7 @@ ActiveRecord::Schema.define(version: 2021_10_06_164955) do t.jsonb "data" t.string "external_id" t.string "fetch_external_data_exceptions", array: true + t.jsonb "value_json" t.index ["dossier_id"], name: "index_champs_on_dossier_id" t.index ["parent_id"], name: "index_champs_on_parent_id" t.index ["private"], name: "index_champs_on_private" @@ -472,11 +473,14 @@ ActiveRecord::Schema.define(version: 2021_10_06_164955) do t.date "birthdate" t.string "birthplace" t.string "france_connect_particulier_id" - t.integer "user_id", null: false + t.integer "user_id" t.string "email_france_connect" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.jsonb "data" + t.string "merge_token" + t.datetime "merge_token_created_at" + t.index ["merge_token"], name: "index_france_connect_informations_on_merge_token" t.index ["user_id"], name: "index_france_connect_informations_on_user_id" end @@ -813,6 +817,7 @@ ActiveRecord::Schema.define(version: 2021_10_06_164955) do add_foreign_key "assign_tos", "groupe_instructeurs" add_foreign_key "attestation_templates", "procedures" add_foreign_key "attestations", "dossiers" + add_foreign_key "avis", "dossiers" add_foreign_key "avis", "experts_procedures" add_foreign_key "bulk_messages_groupe_instructeurs", "bulk_messages" add_foreign_key "bulk_messages_groupe_instructeurs", "groupe_instructeurs" diff --git a/spec/controllers/france_connect/particulier_controller_spec.rb b/spec/controllers/france_connect/particulier_controller_spec.rb index 9a11c282e..3ff97c96b 100644 --- a/spec/controllers/france_connect/particulier_controller_spec.rb +++ b/spec/controllers/france_connect/particulier_controller_spec.rb @@ -1,6 +1,6 @@ describe FranceConnect::ParticulierController, type: :controller do let(:birthdate) { '20150821' } - let(:email) { 'test@test.com' } + let(:email) { 'EMAIL_from_fc@test.com' } let(:user_info) do { @@ -49,37 +49,68 @@ describe FranceConnect::ParticulierController, type: :controller do .and_return(FranceConnectInformation.new(user_info)) end - context 'when france_connect_particulier_id exist in database' do - let!(:france_connect_information) { create(:france_connect_information, :with_user, user_info) } - let(:user) { france_connect_information.user } + context 'when france_connect_particulier_id exists in database' do + let!(:fci) { FranceConnectInformation.create!(user_info.merge(user_id: fc_user&.id)) } - it { expect { subject }.not_to change { FranceConnectInformation.count } } + context 'and is linked to an user' do + let(:fc_user) { create(:user, email: 'associated_user@a.com') } - it do - subject - expect(user.reload.loged_in_with_france_connect).to eq(User.loged_in_with_france_connects.fetch(:particulier)) + it { expect { subject }.not_to change { FranceConnectInformation.count } } + + it 'signs in with the fci associated user' do + subject + expect(controller.current_user).to eq(fc_user) + expect(fc_user.reload.loged_in_with_france_connect).to eq(User.loged_in_with_france_connects.fetch(:particulier)) + end + + context 'and the user has a stored location' do + let(:stored_location) { '/plip/plop' } + before { controller.store_location_for(:user, stored_location) } + + it { is_expected.to redirect_to(stored_location) } + end end - context 'and the user has a stored location' do - let(:stored_location) { '/plip/plop' } - before { controller.store_location_for(:user, stored_location) } + context 'and is linked an instructeur' do + let(:fc_user) { create(:instructeur, email: 'another_email@a.com').user } - it { is_expected.to redirect_to(stored_location) } - end - - context 'and the user is also instructeur' do - let!(:instructeur) { create(:instructeur, email: email) } before { subject } - it { expect(response).to redirect_to(new_user_session_path) } + it do + expect(response).to redirect_to(new_user_session_path) + expect(flash[:alert]).to be_present + end + end - it { expect(flash[:alert]).to be_present } + context 'and is not linked to an user' do + let(:fc_user) { nil } + + context 'and no user with the same email exists' do + it 'creates an user with the same email and log in' do + expect { subject }.to change { User.count }.by(1) + + user = User.last + + expect(user.email).to eq(email.downcase) + expect(controller.current_user).to eq(user) + expect(response).to redirect_to(root_path) + end + end + + context 'and an user with the same email exists' do + let!(:preexisting_user) { create(:user, email: email) } + + it 'redirects to the merge process' do + expect { subject }.not_to change { User.count } + + expect(response).to redirect_to(france_connect_particulier_merge_path(fci.reload.merge_token)) + end + end end end context 'when france_connect_particulier_id does not exist in database' do it { expect { subject }.to change { FranceConnectInformation.count }.by(1) } - it { expect { subject }.to change { FranceConnectInformation.count }.by(1) } describe 'FranceConnectInformation attributs' do let(:stored_fci) { FranceConnectInformation.last } @@ -104,4 +135,138 @@ describe FranceConnect::ParticulierController, type: :controller do it { expect(flash[:alert]).to be_present } end end + + RSpec.shared_examples "a method that needs a valid merge token" do + context 'when the merge token is invalid' do + before do + merge_token + fci.update(merge_token_created_at: 2.years.ago) + end + + it do + if format == :js + subject + expect(response.body).to eq("window.location.href='/'") + else + expect(subject).to redirect_to root_path + end + expect(flash.alert).to eq('Votre compte FranceConnect a expiré, veuillez recommencer.') + end + end + end + + describe '#merge' do + let(:fci) { FranceConnectInformation.create!(user_info) } + let(:merge_token) { fci.create_merge_token! } + let(:format) { :html } + + subject { get :merge, params: { merge_token: merge_token } } + + context 'when the merge token is valid' do + it { expect(subject).to have_http_status(:ok) } + end + + it_behaves_like "a method that needs a valid merge token" + + context 'when the merge token does not exist' do + let(:merge_token) { 'i do not exist' } + + it do + expect(subject).to redirect_to root_path + expect(flash.alert).to eq('Votre compte FranceConnect a expiré, veuillez recommencer.') + end + end + end + + describe '#merge_with_existing_account' do + let(:fci) { FranceConnectInformation.create!(user_info) } + let(:merge_token) { fci.create_merge_token! } + let(:email) { 'EXISTING_account@a.com ' } + let(:password) { 'my-s3cure-p4ssword' } + let(:format) { :js } + + subject { post :merge_with_existing_account, params: { merge_token: merge_token, email: email, password: password }, format: format } + + it_behaves_like "a method that needs a valid merge token" + + context 'when the credentials are ok' do + let!(:user) { create(:user, email: email, password: password) } + + it 'merges the account, signs in, and delete the merge token' do + subject + fci.reload + + expect(fci.user).to eq(user) + expect(fci.merge_token).to be_nil + expect(controller.current_user).to eq(user) + end + + context 'but the targeted user is an instructeur' do + let!(:user) { create(:instructeur, email: email, password: password).user } + + it 'redirects to the root page' do + subject + fci.reload + + expect(fci.user).to be_nil + expect(fci.merge_token).not_to be_nil + expect(controller.current_user).to be_nil + end + end + end + + context 'when the credentials are not ok' do + let!(:user) { create(:user, email: email, password: 'another password #$21$%%') } + + it 'increases the failed attempts counter' do + subject + fci.reload + + expect(fci.user).to be_nil + expect(fci.merge_token).not_to be_nil + expect(controller.current_user).to be_nil + expect(user.reload.failed_attempts).to eq(1) + end + end + end + + describe '#merge_with_new_account' do + let(:fci) { FranceConnectInformation.create!(user_info) } + let(:merge_token) { fci.create_merge_token! } + let(:email) { ' Account@a.com ' } + let(:format) { :js } + + subject { post :merge_with_new_account, params: { merge_token: merge_token, email: email }, format: format } + + it_behaves_like "a method that needs a valid merge token" + + context 'when the email does not belong to any user' do + it 'creates the account, signs in, and delete the merge token' do + subject + fci.reload + + expect(fci.user.email).to eq(email.downcase.strip) + expect(fci.merge_token).to be_nil + expect(controller.current_user).to eq(fci.user) + expect(response.body).to include("window.location.href='/'") + end + end + + context 'when an account with the same email exists' do + let!(:user) { create(:user, email: email) } + + render_views + + it 'asks for the corresponding password' do + subject + fci.reload + + expect(fci.user).to be_nil + expect(fci.merge_token).not_to be_nil + expect(controller.current_user).to be_nil + + expect(response.body).to include('entrez votre mot de passe') + end + end + end end diff --git a/spec/factories/champ.rb b/spec/factories/champ.rb index 03a36d695..b438befbb 100644 --- a/spec/factories/champ.rb +++ b/spec/factories/champ.rb @@ -185,6 +185,10 @@ FactoryBot.define do type_de_champ { association :type_de_champ_annuaire_education, procedure: dossier.procedure } end + factory :champ_cnaf, class: 'Champs::CnafChamp' do + type_de_champ { association :type_de_champ_cnaf, procedure: dossier.procedure } + end + factory :champ_siret, class: 'Champs::SiretChamp' do association :type_de_champ, factory: [:type_de_champ_siret] association :etablissement, factory: [:etablissement] diff --git a/spec/factories/procedure.rb b/spec/factories/procedure.rb index 2651a971e..47a85de6a 100644 --- a/spec/factories/procedure.rb +++ b/spec/factories/procedure.rb @@ -205,6 +205,12 @@ FactoryBot.define do end end + trait :with_cnaf do + after(:build) do |procedure, _evaluator| + build(:type_de_champ_cnaf, procedure: procedure) + end + end + trait :with_explication do after(:build) do |procedure, _evaluator| build(:type_de_champ_explication, procedure: procedure) diff --git a/spec/factories/type_de_champ.rb b/spec/factories/type_de_champ.rb index ae6fa4cc1..9f5223e86 100644 --- a/spec/factories/type_de_champ.rb +++ b/spec/factories/type_de_champ.rb @@ -151,6 +151,9 @@ FactoryBot.define do factory :type_de_champ_annuaire_education do type_champ { TypeDeChamp.type_champs.fetch(:annuaire_education) } end + factory :type_de_champ_cnaf do + type_champ { TypeDeChamp.type_champs.fetch(:cnaf) } + end factory :type_de_champ_carte do type_champ { TypeDeChamp.type_champs.fetch(:carte) } end diff --git a/spec/features/api_particulier/api_particulier_spec.rb b/spec/features/api_particulier/api_particulier_spec.rb new file mode 100644 index 000000000..de793f453 --- /dev/null +++ b/spec/features/api_particulier/api_particulier_spec.rb @@ -0,0 +1,167 @@ +feature 'fetch API Particulier Data', js: true do + let(:administrateur) { create(:administrateur) } + + let(:expected_token) { 'd7e9c9f4c3ca00caadde31f50fd4521a' } + + let(:expected_sources) do + { + "cnaf" => + { + "adresse" => ["identite", "complementIdentite", "complementIdentiteGeo", "numeroRue", "lieuDit", "codePostalVille", "pays"], + "allocataires" => ["nomPrenom", "dateDeNaissance", "sexe"], + "enfants" => ["nomPrenom", "dateDeNaissance", "sexe"], + "quotient_familial" => ["quotientFamilial", "annee", "mois"] + } + } + end + + before do + stub_const("API_PARTICULIER_URL", "https://particulier.api.gouv.fr/api") + Flipper.enable(:api_particulier) + end + + context "when an administrateur is logged" do + let(:procedure) do + create(:procedure, :with_service, :with_instructeur, + aasm_state: :brouillon, + administrateurs: [administrateur], + libelle: "libellé de la procédure", + path: "libelle-de-la-procedure") + end + + before { login_as administrateur.user, scope: :user } + + scenario 'it can enable api particulier' do + visit admin_procedure_path(procedure) + expect(page).to have_content("Configurer le jeton API particulier") + + find('#api-particulier').click + expect(page).to have_current_path(admin_procedure_api_particulier_path(procedure)) + + find('#add-jeton').click + expect(page).to have_current_path(admin_procedure_api_particulier_jeton_path(procedure)) + + fill_in 'procedure_api_particulier_token', with: expected_token + VCR.use_cassette("api_particulier/success/introspect") { click_on 'Enregistrer' } + expect(page).to have_text('Le jeton a bien été mis à jour') + expect(page).to have_current_path(admin_procedure_api_particulier_sources_path(procedure)) + + ['allocataires', 'enfants'].each do |scope| + within("##{scope}") do + check('noms et prénoms') + check('date de naissance') + check('sexe') + end + end + + within("#adresse") do + check('identité') + check('complément d’identité') + check('complément d’identité géographique') + check('numéro et rue') + check('lieu-dit') + check('code postal et ville') + check('pays') + end + + within("#quotient_familial") do + check('quotient familial') + check('année') + check('mois') + end + + click_on "Enregistrer" + + within("#enfants") do + expect(find('input[value=nomPrenom]')).to be_checked + end + + expect(procedure.reload.api_particulier_sources).to eq(expected_sources) + + visit champs_admin_procedure_path(procedure) + + add_champ + select('Données de la Caisse nationale des allocations familiales', from: 'champ-0-type_champ') + fill_in 'champ-0-libelle', with: 'libellé de champ' + blur + expect(page).to have_content('Formulaire enregistré') + + visit admin_procedure_path(procedure) + find('#publish-procedure-link').click + expect(find_field('procedure_path').value).to eq procedure.path + fill_in 'lien_site_web', with: 'http://some.website' + click_on 'Publier' + + expect(page).to have_text('Démarche publiée') + end + end + + context 'when an user is logged' do + let(:user) { create(:user) } + let(:api_particulier_token) { '29eb50b65f64e8e00c0847a8bbcbd150e1f847' } + let(:numero_allocataire) { '5843972' } + let(:code_postal) { '92110' } + let(:instructeur) { create(:instructeur) } + + let(:procedure) do + create(:procedure, :for_individual, :with_service, :with_cnaf, :published, + libelle: "libellé de la procédure", + path: "libelle-de-la-procedure", + instructeurs: [instructeur], + api_particulier_sources: expected_sources, + api_particulier_token: api_particulier_token) + end + + before { login_as user, scope: :user } + + scenario 'it can fill an cnaf champ' do + visit commencer_path(path: procedure.path) + click_on 'Commencer la démarche' + + choose 'Monsieur' + fill_in 'individual_nom', with: 'Nom' + fill_in 'individual_prenom', with: 'Prenom' + + click_button('Continuer') + + fill_in 'Le numéro d’allocataire CAF', with: numero_allocataire + fill_in 'Le code postal', with: 'wrong_code' + + blur + expect(page).to have_css('span', text: 'Brouillon enregistré', visible: true) + + dossier = Dossier.last + expect(dossier.champs.first.code_postal).to eq('wrong_code') + + click_on 'Déposer le dossier' + expect(page).to have_content(/code postal doit posséder 5 caractères/) + + fill_in 'Le code postal', with: code_postal + + VCR.use_cassette("api_particulier/success/composition_familiale") do + perform_enqueued_jobs { click_on 'Déposer le dossier' } + end + + visit demande_dossier_path(dossier) + expect(page).to have_content(/Des données.*ont été reçues depuis la CAF/) + + log_out + + login_as instructeur.user, scope: :user + + visit instructeur_dossier_path(procedure, dossier) + + expect(page).to have_content('code postal et ville 92110 Clichy') + expect(page).to have_content('identité Mr SNOW Eric') + expect(page).to have_content('complément d’identité ne connait rien') + expect(page).to have_content('numéro et rue 109 rue La Boétie') + expect(page).to have_content('pays FRANCE') + expect(page).to have_content('complément d’identité géographique au nord de paris') + expect(page).to have_content('lieu-dit glagla') + expect(page).to have_content('ERIC SNOW masculin 07/01/1991') + expect(page).to have_content('SANSA SNOW féminin 15/01/1992') + expect(page).to have_content('PAUL SNOW masculin 04/01/2018') + expect(page).to have_content('1856 6 2021') + end + end +end diff --git a/spec/features/france_connect/france_connect_particulier_spec.rb b/spec/features/france_connect/france_connect_particulier_spec.rb index 7dbbbef1f..4498f8031 100644 --- a/spec/features/france_connect/france_connect_particulier_spec.rb +++ b/spec/features/france_connect/france_connect_particulier_spec.rb @@ -21,62 +21,85 @@ feature 'France Connect Particulier Connexion' do end context 'when user is on login page' do - before do - visit new_user_session_path - end + before { visit new_user_session_path } scenario 'link to France Connect is present' do expect(page).to have_css('.france-connect-login-button') end context 'and click on france connect link' do - let(:code) { 'plop' } - context 'when authentification is ok' do before do allow_any_instance_of(FranceConnectParticulierClient).to receive(:authorization_uri).and_return(france_connect_particulier_callback_path(code: code)) allow(FranceConnectService).to receive(:retrieve_user_informations_particulier).and_return(france_connect_information) end - context 'when is the first connexion' do - let(:france_connect_information) do - build(:france_connect_information, - france_connect_particulier_id: france_connect_particulier_id, - given_name: given_name, - family_name: family_name, - birthdate: birthdate, - birthplace: birthplace, - gender: gender, - email_france_connect: email) + context 'when no user is linked' do + let(:france_connect_information) { build(:france_connect_information, user_info) } + + context 'and no user has the same email' do + before { page.find('.france-connect-login-button').click } + + scenario 'he is redirected to user dossiers page' do + expect(page).to have_content('Dossiers') + expect(User.find_by(email: email)).not_to be nil + end end - before do - page.find('.france-connect-login-button').click - end + context 'and an user exists with the same email' do + let!(:user) { create(:user, email: email, password: 'my-s3cure-p4ssword') } - scenario 'he is redirected to user dossiers page' do - expect(page).to have_content('Dossiers') + before do + page.find('.france-connect-login-button').click + end + + scenario 'he is redirected to the merge page' do + expect(page).to have_content('Fusion des comptes') + end + + scenario 'it merges its account' do + page.find('#it-is-mine').click + fill_in 'password', with: 'my-s3cure-p4ssword' + click_on 'Fusionner les comptes' + + expect(page).to have_content('Dossiers') + end + + scenario 'it uses another email that belongs to nobody' do + page.find('#it-is-not-mine').click + fill_in 'email', with: 'new_email@a.com' + click_on 'Utiliser ce mail' + + expect(page).to have_content('Dossiers') + end + + context 'and the user wants an email that belongs to another account', js: true do + let!(:another_user) { create(:user, email: 'an_existing_email@a.com', password: 'my-s3cure-p4ssword') } + + scenario 'it uses another email that belongs to another account' do + page.find('#it-is-not-mine').click + fill_in 'email', with: 'an_existing_email@a.com' + click_on 'Utiliser ce mail' + + expect(page).to have_css('#password-for-another-account', visible: true) + + within '.new-account-password-confirmation' do + fill_in 'password', with: 'my-s3cure-p4ssword' + click_on 'Fusionner les comptes' + end + + expect(page).to have_content('Dossiers') + end + end end end - context 'when is not the first connexion' do + context 'when a user is linked' do let!(:france_connect_information) do - create(:france_connect_information, - :with_user, - france_connect_particulier_id: france_connect_particulier_id, - given_name: given_name, - family_name: family_name, - birthdate: birthdate, - birthplace: birthplace, - gender: gender, - email_france_connect: email, - created_at: Time.zone.parse('12/12/2012'), - updated_at: Time.zone.parse('12/12/2012')) + create(:france_connect_information, :with_user, user_info.merge(created_at: Time.zone.parse('12/12/2012'), updated_at: Time.zone.parse('12/12/2012'))) end - before do - page.find('.france-connect-login-button').click - end + before { page.find('.france-connect-login-button').click } scenario 'he is redirected to user dossiers page' do expect(page).to have_content('Dossiers') diff --git a/spec/features/routing/full_scenario_spec.rb b/spec/features/routing/full_scenario_spec.rb index c09c3f706..47cdee2d5 100644 --- a/spec/features/routing/full_scenario_spec.rb +++ b/spec/features/routing/full_scenario_spec.rb @@ -63,7 +63,7 @@ feature 'The routing', js: true do # publish publish_procedure(procedure) - log_out(old_layout: true) + log_out # 2 users fill a dossier in each group user_send_dossier(scientifique_user, 'scientifique') @@ -222,15 +222,4 @@ feature 'The routing', js: true do expect(page).to have_text('Mot de passe enregistré') end - - def log_out(old_layout: false) - if old_layout - page.all('.dropdown-button').first.click - click_on 'Se déconnecter' - else - click_button(title: 'Mon compte') - click_on 'Se déconnecter' - end - expect(page).to have_current_path(root_path) - end end diff --git a/spec/fixtures/cassettes/api_particulier/success/composition_familiale.yml b/spec/fixtures/cassettes/api_particulier/success/composition_familiale.yml index 7732aaf4d..22497f8a6 100644 --- a/spec/fixtures/cassettes/api_particulier/success/composition_familiale.yml +++ b/spec/fixtures/cassettes/api_particulier/success/composition_familiale.yml @@ -2,7 +2,7 @@ http_interactions: - request: method: get - uri: https://particulier-test.api.gouv.fr/api/v2/composition-familiale?codePostal=92110&numeroAllocataire=5843972 + uri: https://particulier.api.gouv.fr/api/v2/composition-familiale?codePostal=92110&numeroAllocataire=5843972 body: encoding: US-ASCII string: '' diff --git a/spec/fixtures/cassettes/api_particulier/success/composition_familiale_invalid.yml b/spec/fixtures/cassettes/api_particulier/success/composition_familiale_invalid.yml index e562a0871..49b3f8ccf 100644 --- a/spec/fixtures/cassettes/api_particulier/success/composition_familiale_invalid.yml +++ b/spec/fixtures/cassettes/api_particulier/success/composition_familiale_invalid.yml @@ -2,7 +2,7 @@ http_interactions: - request: method: get - uri: https://particulier-test.api.gouv.fr/api/v2/composition-familiale?codePostal=92110&numeroAllocataire=5843972 + uri: https://particulier.api.gouv.fr/api/v2/composition-familiale?codePostal=92110&numeroAllocataire=5843972 body: encoding: US-ASCII string: '' diff --git a/spec/lib/api_particulier/cnaf_adapter_spec.rb b/spec/lib/api_particulier/cnaf_adapter_spec.rb index 362a0c5f5..bd55a2a5f 100644 --- a/spec/lib/api_particulier/cnaf_adapter_spec.rb +++ b/spec/lib/api_particulier/cnaf_adapter_spec.rb @@ -1,7 +1,7 @@ -describe APIParticulier::CNAFAdapter do +describe APIParticulier::CnafAdapter do let(:adapter) { described_class.new(api_particulier_token, numero_allocataire, code_postal, requested_sources) } - before { stub_const("API_PARTICULIER_URL", "https://particulier-test.api.gouv.fr/api") } + before { stub_const("API_PARTICULIER_URL", "https://particulier.api.gouv.fr/api") } describe '#to_params' do let(:api_particulier_token) { '29eb50b65f64e8e00c0847a8bbcbd150e1f847' } @@ -63,7 +63,7 @@ describe APIParticulier::CNAFAdapter do context 'when no sources is requested' do let(:requested_sources) { {} } - it { expect { subject }.to raise_error(APIParticulier::CNAFAdapter::InvalidSchemaError) } + it { expect { subject }.to raise_error(APIParticulier::CnafAdapter::InvalidSchemaError) } end end end diff --git a/spec/lib/api_particulier/services/sources_service_spec.rb b/spec/lib/api_particulier/services/sources_service_spec.rb index 33e0c5208..9619732b5 100644 --- a/spec/lib/api_particulier/services/sources_service_spec.rb +++ b/spec/lib/api_particulier/services/sources_service_spec.rb @@ -31,6 +31,12 @@ describe APIParticulier::Services::SourcesService do it { is_expected.to match(cnaf_allocataires_and_enfants) } end + + context 'when a procedure has an unknown scope' do + let(:api_particulier_scopes) { ['unknown_scope'] } + + it { is_expected.to match({}) } + end end describe '#sanitize' do diff --git a/spec/models/champs/cnaf_champ_spec.rb b/spec/models/champs/cnaf_champ_spec.rb new file mode 100644 index 000000000..405c820a2 --- /dev/null +++ b/spec/models/champs/cnaf_champ_spec.rb @@ -0,0 +1,103 @@ +describe Champs::CnafChamp, type: :model do + let(:champ) { described_class.new } + + describe 'numero_allocataire and code_postal' do + before do + champ.numero_allocataire = '1234567' + champ.code_postal = '12345' + end + + it 'saves numero_allocataire and code_postal' do + expect(champ.numero_allocataire).to eq('1234567') + expect(champ.code_postal).to eq('12345') + end + end + + describe 'external_id' do + context 'when only one data is given' do + before do + champ.numero_allocataire = '1234567' + champ.save + end + + it { expect(champ.external_id).to be_nil } + end + + context 'when all data required for an external fetch are given' do + before do + champ.numero_allocataire = '1234567' + champ.code_postal = '12345' + champ.save + end + + it { expect(JSON.parse(champ.external_id)).to eq({ "code_postal" => "12345", "numero_allocataire" => "1234567" }) } + end + end + + describe '#validate' do + let(:numero_allocataire) { '1234567' } + let(:code_postal) { '12345' } + let(:champ) { described_class.new(dossier: create(:dossier), type_de_champ: create(:type_de_champ_cnaf)) } + let(:validation_context) { :create } + + subject { champ.valid?(validation_context) } + + before do + champ.numero_allocataire = numero_allocataire + champ.code_postal = code_postal + end + + context 'when numero_allocataire and code_postal are valids' do + it { is_expected.to be true } + end + + context 'when numero_allocataire and code_postal are nil' do + let(:numero_allocataire) { nil } + let(:code_postal) { nil } + + it { is_expected.to be true } + end + + context 'when only code_postal is nil' do + let(:code_postal) { nil } + + it do + is_expected.to be false + expect(champ.errors.full_messages).to eq(["Code postal doit posséder 5 caractères"]) + end + end + + context 'when only numero_allocataire is nil' do + let(:numero_allocataire) { nil } + + it do + is_expected.to be false + expect(champ.errors.full_messages).to eq(["Numero allocataire doit être composé au maximum de 7 chiffres"]) + end + end + + context 'when numero_allocataire is invalid' do + let(:numero_allocataire) { '123456a' } + + it do + is_expected.to be false + expect(champ.errors.full_messages).to eq(["Numero allocataire doit être composé au maximum de 7 chiffres"]) + end + + context 'and the validation_context is :brouillon' do + let(:validation_context) { :brouillon } + + it { is_expected.to be true } + end + end + + context 'when code_postal is invalid' do + let(:code_postal) { '123456' } + + it do + is_expected.to be false + expect(champ.errors.full_messages).to eq(["Code postal doit posséder 5 caractères"]) + end + end + end +end diff --git a/spec/models/expert_spec.rb b/spec/models/expert_spec.rb index 5b3ebf607..3a27f23aa 100644 --- a/spec/models/expert_spec.rb +++ b/spec/models/expert_spec.rb @@ -19,6 +19,12 @@ RSpec.describe Expert, type: :model do subject { new_expert.merge(old_expert) } + context 'when the old expert does not exist' do + let(:old_expert) { nil } + + it { expect { subject }.not_to raise_error } + end + context 'when an old expert access a procedure' do let(:procedure) { create(:procedure) } @@ -28,7 +34,7 @@ RSpec.describe Expert, type: :model do end it 'transfers the access to the new expert' do - expect(procedure.reload.experts). to match_array(new_expert) + expect(procedure.reload.experts).to match_array(new_expert) end end diff --git a/spec/models/france_connect_information_spec.rb b/spec/models/france_connect_information_spec.rb index 6e8a0822b..621c729b3 100644 --- a/spec/models/france_connect_information_spec.rb +++ b/spec/models/france_connect_information_spec.rb @@ -9,24 +9,52 @@ describe FranceConnectInformation, type: :model do describe 'associate_user!' do context 'when there is no user with same email' do + let(:email) { 'A@email.com' } let(:fci) { build(:france_connect_information) } - let(:subject) { fci.associate_user! } + + subject { fci.associate_user!(email) } it { expect { subject }.to change(User, :count).by(1) } + it do subject - expect(fci.user.email).to eq(fci.email_france_connect) + expect(fci.user.email).to eq('a@email.com') + end + end + end + + describe '#valid_for_merge?' do + let(:fci) { create(:france_connect_information) } + + subject { fci.valid_for_merge? } + + context 'when the merge token is young enough' do + before { fci.merge_token_created_at = 1.minute.ago } + + it { is_expected.to be(true) } + + context 'but the fci is already linked to an user' do + before { fci.update(user: create(:user)) } + + it { is_expected.to be(false) } end end - context 'when a user with same email (but who is not an instructeur) exist' do - let(:user) { create(:user) } - let(:fci) { build(:france_connect_information, email_france_connect: user.email) } - let(:subject) { fci.associate_user! } + context 'when the merge token is too old' do + before { fci.merge_token_created_at = (FranceConnectInformation::MERGE_VALIDITY + 1.minute).ago } - before { subject } + it { is_expected.to be(false) } + end + end - it { expect(fci.user).to eq(user) } + describe '#create_merge_token!' do + let(:fci) { create(:france_connect_information) } + + it 'returns a merge_token and register it s creation date' do + token = fci.create_merge_token! + + expect(fci.merge_token).to eq(token) + expect(fci.merge_token_created_at).not_to be_nil end end end diff --git a/spec/models/instructeur_spec.rb b/spec/models/instructeur_spec.rb index a9ffc2c4a..768feef92 100644 --- a/spec/models/instructeur_spec.rb +++ b/spec/models/instructeur_spec.rb @@ -743,6 +743,12 @@ describe Instructeur, type: :model do subject { new_instructeur.merge(old_instructeur) } + context 'when the old instructeur does not exist' do + let(:old_instructeur) { nil } + + it { expect { subject }.not_to raise_error } + end + context 'when an procedure is assigned to the old instructeur' do let(:procedure) { create(:procedure) } diff --git a/spec/services/procedure_export_service_spec.rb b/spec/services/procedure_export_service_spec.rb index bca98ba9c..6888e77e1 100644 --- a/spec/services/procedure_export_service_spec.rb +++ b/spec/services/procedure_export_service_spec.rb @@ -77,6 +77,7 @@ describe ProcedureExportService do "titre_identite", "iban", "annuaire_education", + "cnaf", "text" ] end @@ -164,6 +165,7 @@ describe ProcedureExportService do "titre_identite", "iban", "annuaire_education", + "cnaf", "text" ] end @@ -247,6 +249,7 @@ describe ProcedureExportService do "titre_identite", "iban", "annuaire_education", + "cnaf", "text" ] end diff --git a/spec/support/feature_helpers.rb b/spec/support/feature_helpers.rb index 5b63e9386..371a906e8 100644 --- a/spec/support/feature_helpers.rb +++ b/spec/support/feature_helpers.rb @@ -142,6 +142,13 @@ module FeatureHelpers have_css("##{form_id_for(libelle)}[value=\"#{with}\"]") end + def log_out + click_button(title: 'Mon compte') + click_on 'Se déconnecter' + + expect(page).to have_current_path(root_path) + end + # Keep the brower window open after a test success of failure, to # allow inspecting the page or the console. #