diff --git a/.eslintrc.js b/.eslintrc.js index e9ea62e88..cc5432980 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -23,6 +23,7 @@ module.exports = { rules: { 'prettier/prettier': 'error', 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'error', 'react/prop-types': 'off' }, settings: { @@ -51,7 +52,13 @@ module.exports = { 'plugin:@typescript-eslint/recommended', 'plugin:react-hooks/recommended', 'prettier' - ] + ], + rules: { + 'prettier/prettier': 'error', + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'error', + '@typescript-eslint/no-explicit-any': 'error' + } } ] }; diff --git a/.github/actions/ci-setup-rails/action.yml b/.github/actions/ci-setup-rails/action.yml index 7f4b9edb8..15615d7e9 100644 --- a/.github/actions/ci-setup-rails/action.yml +++ b/.github/actions/ci-setup-rails/action.yml @@ -12,11 +12,12 @@ runs: - name: Setup Node uses: actions/setup-node@v2 with: - node-version: '14' cache: 'yarn' - name: Install Node modules - run: yarn install --frozen-lockfile + run: | + node --version + yarn install --frozen-lockfile shell: bash - name: Setup environment variables diff --git a/.node-version b/.node-version new file mode 100644 index 000000000..832d38506 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +16.14.0 diff --git a/Gemfile b/Gemfile index b56231cea..0a5ce4a84 100644 --- a/Gemfile +++ b/Gemfile @@ -83,7 +83,7 @@ gem 'spreadsheet_architect' gem 'typhoeus' gem 'warden' gem 'webpacker' -gem 'zipline', github: 'fringd/zipline', ref: 'd637bbff2' # Unreleased 1.3.0, with a fix for Ruby 3.0 kwargs +gem 'zipline' gem 'zxcvbn-ruby', require: 'zxcvbn' group :test do @@ -120,7 +120,7 @@ end group :development, :test do gem 'graphql-schema_comparator' - gem 'mina', git: 'https://github.com/mina-deploy/mina.git', require: false # Deploy + gem 'mina', require: false # Deploy gem 'pry-byebug' # Call 'byebug' anywhere in the code to stop execution and get a debugger console gem 'rspec-rails' gem 'simple_xlsx_reader' diff --git a/Gemfile.lock b/Gemfile.lock index 74daed95a..50bdda94b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,60 +1,43 @@ -GIT - remote: https://github.com/fringd/zipline.git - revision: d637bbff262f59718d23a65f50b50163b8ba749f - ref: d637bbff2 - specs: - zipline (1.3.0) - actionpack (>= 3.2.1, < 7.0) - zip_tricks (>= 4.2.1, < 6.0) - -GIT - remote: https://github.com/mina-deploy/mina.git - revision: 84fa84c7f7f94f9518ef9b7099396ab6676b5881 - specs: - mina (1.2.3) - open4 (~> 1.3.4) - rake - GEM remote: https://rubygems.org/ specs: aasm (5.2.0) concurrent-ruby (~> 1.0) acsv (0.0.1) - actioncable (6.1.4.4) - actionpack (= 6.1.4.4) - activesupport (= 6.1.4.4) + actioncable (6.1.4.6) + actionpack (= 6.1.4.6) + activesupport (= 6.1.4.6) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.1.4.4) - actionpack (= 6.1.4.4) - activejob (= 6.1.4.4) - activerecord (= 6.1.4.4) - activestorage (= 6.1.4.4) - activesupport (= 6.1.4.4) + actionmailbox (6.1.4.6) + actionpack (= 6.1.4.6) + activejob (= 6.1.4.6) + activerecord (= 6.1.4.6) + activestorage (= 6.1.4.6) + activesupport (= 6.1.4.6) mail (>= 2.7.1) - actionmailer (6.1.4.4) - actionpack (= 6.1.4.4) - actionview (= 6.1.4.4) - activejob (= 6.1.4.4) - activesupport (= 6.1.4.4) + actionmailer (6.1.4.6) + actionpack (= 6.1.4.6) + actionview (= 6.1.4.6) + activejob (= 6.1.4.6) + activesupport (= 6.1.4.6) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.1.4.4) - actionview (= 6.1.4.4) - activesupport (= 6.1.4.4) + actionpack (6.1.4.6) + actionview (= 6.1.4.6) + activesupport (= 6.1.4.6) rack (~> 2.0, >= 2.0.9) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.1.4.4) - actionpack (= 6.1.4.4) - activerecord (= 6.1.4.4) - activestorage (= 6.1.4.4) - activesupport (= 6.1.4.4) + actiontext (6.1.4.6) + actionpack (= 6.1.4.6) + activerecord (= 6.1.4.6) + activestorage (= 6.1.4.6) + activesupport (= 6.1.4.6) nokogiri (>= 1.8.5) - actionview (6.1.4.4) - activesupport (= 6.1.4.4) + actionview (6.1.4.6) + activesupport (= 6.1.4.6) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) @@ -72,26 +55,26 @@ GEM activemodel (>= 5.2.0) activestorage (>= 5.2.0) activesupport (>= 5.2.0) - activejob (6.1.4.4) - activesupport (= 6.1.4.4) + activejob (6.1.4.6) + activesupport (= 6.1.4.6) globalid (>= 0.3.6) - activemodel (6.1.4.4) - activesupport (= 6.1.4.4) - activerecord (6.1.4.4) - activemodel (= 6.1.4.4) - activesupport (= 6.1.4.4) - activestorage (6.1.4.4) - actionpack (= 6.1.4.4) - activejob (= 6.1.4.4) - activerecord (= 6.1.4.4) - activesupport (= 6.1.4.4) + activemodel (6.1.4.6) + activesupport (= 6.1.4.6) + activerecord (6.1.4.6) + activemodel (= 6.1.4.6) + activesupport (= 6.1.4.6) + activestorage (6.1.4.6) + actionpack (= 6.1.4.6) + activejob (= 6.1.4.6) + activerecord (= 6.1.4.6) + activesupport (= 6.1.4.6) marcel (~> 1.0.0) mini_mime (>= 1.1.0) activestorage-openstack (1.5.1) fog-openstack (~> 1.0) marcel rails (>= 5.2.2) - activesupport (6.1.4.4) + activesupport (6.1.4.6) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -183,6 +166,7 @@ GEM descendants_tracker (~> 0.0.1) concurrent-ruby (1.1.9) connection_pool (2.2.3) + content_disposition (1.0.0) crack (0.4.5) rexml crass (1.0.6) @@ -351,7 +335,7 @@ GEM domain_name (~> 0.5) http_accept_language (2.1.1) httpclient (2.8.3) - i18n (1.8.11) + i18n (1.10.0) concurrent-ruby (~> 1.0) i18n-tasks (0.9.33) activesupport (>= 4.0.2) @@ -417,7 +401,7 @@ GEM railties (>= 4) request_store (~> 1.0) logstash-event (1.2.02) - loofah (2.13.0) + loofah (2.14.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) mail (2.7.1) @@ -431,9 +415,12 @@ GEM mime-types (3.3.1) mime-types-data (~> 3.2015) mime-types-data (3.2021.0212) + mina (1.2.4) + open4 (~> 1.3.4) + rake mini_magick (4.11.0) mini_mime (1.1.2) - mini_portile2 (2.6.1) + mini_portile2 (2.7.1) minitest (5.15.0) momentjs-rails (2.20.1) railties (>= 3.1) @@ -444,8 +431,8 @@ GEM ruby2_keywords (~> 0.0.1) netrc (0.11.0) nio4r (2.5.8) - nokogiri (1.12.5) - mini_portile2 (~> 2.6.1) + nokogiri (1.13.1) + mini_portile2 (~> 2.7.0) racc (~> 1.4) open4 (1.3.4) openid_connect (1.3.0) @@ -515,20 +502,20 @@ GEM rack rack-test (1.1.0) rack (>= 1.0, < 3) - rails (6.1.4.4) - actioncable (= 6.1.4.4) - actionmailbox (= 6.1.4.4) - actionmailer (= 6.1.4.4) - actionpack (= 6.1.4.4) - actiontext (= 6.1.4.4) - actionview (= 6.1.4.4) - activejob (= 6.1.4.4) - activemodel (= 6.1.4.4) - activerecord (= 6.1.4.4) - activestorage (= 6.1.4.4) - activesupport (= 6.1.4.4) + rails (6.1.4.6) + actioncable (= 6.1.4.6) + actionmailbox (= 6.1.4.6) + actionmailer (= 6.1.4.6) + actionpack (= 6.1.4.6) + actiontext (= 6.1.4.6) + actionview (= 6.1.4.6) + activejob (= 6.1.4.6) + activemodel (= 6.1.4.6) + activerecord (= 6.1.4.6) + activestorage (= 6.1.4.6) + activesupport (= 6.1.4.6) bundler (>= 1.15.0) - railties (= 6.1.4.4) + railties (= 6.1.4.6) sprockets-rails (>= 2.0.0) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) @@ -547,9 +534,9 @@ GEM rails-i18n (6.0.0) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 7) - railties (6.1.4.4) - actionpack (= 6.1.4.4) - activesupport (= 6.1.4.4) + railties (6.1.4.6) + actionpack (= 6.1.4.6) + activesupport (= 6.1.4.6) method_source rake (>= 0.13) thor (~> 1.0) @@ -768,8 +755,12 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.5.3) + zeitwerk (2.5.4) zip_tricks (5.6.0) + zipline (1.4.1) + actionpack (>= 6.0, < 8.0) + content_disposition (~> 1.0) + zip_tricks (>= 4.2.1, < 6.0) zxcvbn-ruby (1.2.0) PLATFORMS @@ -842,7 +833,7 @@ DEPENDENCIES lograge logstash-event mailjet - mina! + mina openid_connect pg phonelib @@ -892,7 +883,7 @@ DEPENDENCIES webdrivers (~> 4.0) webmock webpacker - zipline! + zipline zxcvbn-ruby BUNDLED WITH diff --git a/app/assets/images/login-with-fc-hover.svg b/app/assets/images/login-with-fc-hover.svg index 2a580527e..5a2d16909 100644 --- a/app/assets/images/login-with-fc-hover.svg +++ b/app/assets/images/login-with-fc-hover.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/assets/images/login-with-fc.svg b/app/assets/images/login-with-fc.svg index afc1e8d89..3a95ff212 100644 --- a/app/assets/images/login-with-fc.svg +++ b/app/assets/images/login-with-fc.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/assets/stylesheets/agentconnect.scss b/app/assets/stylesheets/agentconnect.scss new file mode 100644 index 000000000..350f85d52 --- /dev/null +++ b/app/assets/stylesheets/agentconnect.scss @@ -0,0 +1,27 @@ +@import "colors"; +@import "constants"; + +#agentconnect { + .agent { + color: $blue-france-500; + text-align: center; + font-size: 28px; + font-weight: bold; + } + + .box { + background-color: #F2F2F9; + padding: $default-padding; + + ul { + list-style: disc; + padding-left: inherit; + } + } + + .citizen { + font-size: 16px; + color: $blue-france-500; + font-weight: bold; + } +} diff --git a/app/assets/stylesheets/auth.scss b/app/assets/stylesheets/auth.scss index 4cc36d96e..410771461 100644 --- a/app/assets/stylesheets/auth.scss +++ b/app/assets/stylesheets/auth.scss @@ -3,17 +3,23 @@ @import "placeholders"; @import "mixins"; -#auth { +#auth, +#agentconnect { // On small screens, hide the procedure description text on auth pages. // It avoids pushing the sign-in/sign-up form out of the viewport. // // The procedure description can still be read from the /commencer // pages. @media (max-width: $two-columns-breakpoint) { - .procedure-preview { + .procedure-preview, + .agent-intro { display: none; } } + + .column { + padding-top: 2 * $default-spacer; + } } .auth-form { @@ -53,6 +59,10 @@ } .sign-in-form .form { + input[type="email"] { + margin-bottom: $default-padding; + } + input[type="password"] { margin-bottom: $default-spacer; } @@ -61,3 +71,10 @@ margin-bottom: 0; } } + +#session-new { + .important-header { + font-weight: bold; + font-size: 18px; + } +} diff --git a/app/assets/stylesheets/france-connect-login.scss b/app/assets/stylesheets/france-connect-login.scss index f54289631..20c66f404 100644 --- a/app/assets/stylesheets/france-connect-login.scss +++ b/app/assets/stylesheets/france-connect-login.scss @@ -15,8 +15,8 @@ .france-connect-login-button { display: inline-block; - height: 52px; - width: 186px; + height: 60px; + width: 230px; margin: auto; margin-bottom: 8px; background-image: image-url("login-with-fc.svg"), image-url("login-with-fc-hover.svg"); diff --git a/app/assets/stylesheets/helpers.scss b/app/assets/stylesheets/helpers.scss deleted file mode 100644 index 4577ef84c..000000000 --- a/app/assets/stylesheets/helpers.scss +++ /dev/null @@ -1,42 +0,0 @@ -@import "constants"; - -.m-0 { - margin: 0px !important; -} - -.mb-1 { - margin-bottom: $default-spacer; -} - -.mr-1 { - margin-right: $default-spacer !important; -} - -.mb-4 { - margin-bottom: 4 * $default-spacer !important; -} - -.ml-1 { - margin-left: $default-spacer; -} - -.pl-0 { - padding-left: 0px !important; -} - -.p-0 { - padding: 0px !important; -} - -.bold { - font-weight: bold; -} - -.numbers-delimiter { - display: inline-block; - width: 5px; -} - -.text-center { - text-align: center; -} diff --git a/app/assets/stylesheets/merci.scss b/app/assets/stylesheets/merci.scss index e52b91bb2..b40f676a2 100644 --- a/app/assets/stylesheets/merci.scss +++ b/app/assets/stylesheets/merci.scss @@ -2,38 +2,8 @@ @import "common"; @import "constants"; -.merci { - text-align: center; - margin-bottom: 60px; - +.merci .monavis { img { - margin-top: 4 * $default-padding; - } - - h1 { - margin: (2 * $default-padding) 0; - } - - b { - font-weight: bold; - } - - .send { - margin-bottom: 2 * $default-padding; - font-size: 20px; - } - - p { - margin: $default-padding; - } - - a { - margin-top: 40px; - } - - .monavis { - img { - margin-top: 2 * $default-padding; - } + margin-top: 2 * $default-padding; } } diff --git a/app/assets/stylesheets/procedure_context.scss b/app/assets/stylesheets/procedure_context.scss index 5cc67ad4d..24f600222 100644 --- a/app/assets/stylesheets/procedure_context.scss +++ b/app/assets/stylesheets/procedure_context.scss @@ -22,6 +22,12 @@ $procedure-description-line-height: 22px; font-weight: bold; } + .small-simple { + font-size: 16px; + color: $blue-france-500; + font-weight: bold; + } + .close-procedure { font-size: 12px; } diff --git a/app/assets/stylesheets/utils.scss b/app/assets/stylesheets/utils.scss index 535217901..14e90df81 100644 --- a/app/assets/stylesheets/utils.scss +++ b/app/assets/stylesheets/utils.scss @@ -1,6 +1,7 @@ @import "colors"; @import "constants"; +// floats .pull-left { float: left; } @@ -13,6 +14,9 @@ clear: both; } + +// text +.text-center, .center { text-align: center; } @@ -21,12 +25,21 @@ text-align: right; } -.hidden { - display: none; +.text-sm { + font-size: 14px; } -.width-100 { - width: 100%; +.text-lg { + font-size: 18px; +} + +.bold { + font-weight: bold; +} + +.numbers-delimiter { + display: inline-block; + width: 5px; } .empty-text { @@ -45,95 +58,42 @@ } } +// display +.hidden { + display: none; +} + +// sizing +.width-100 { + width: 100%; +} + +// who known .highlighted { background: $orange-bg; color: $black; } -.text-sm { - font-size: 14px; -} +// generate spacer utility like bootstrap my-2 -> margin-left/right: 2 * $default-spacer +// using $direction.key as css modifier, $direction.values to set css properties +// scale it using $steps +$directions: ( + "t": ("margin-top"), + "r": ("margin-right"), + "b": ("margin-bottom"), + "l": ("margin-left"), + "x": ("margin-left", "margin-right"), + "y": ("margin-top", "margin-bottom"), + "": ("margin") +); +$steps: (0, 1, 2, 3, 4, 5, 6, 7, 8); -.text-lg { - font-size: 18px; -} - -.mt-1 { - margin-top: $default-spacer; -} - -.mt-2 { - margin-top: 2 * $default-spacer; -} - -.mt-3 { - margin-top: 3 * $default-spacer; -} - -.mt-4 { - margin-top: 4 * $default-spacer; -} - -.mt-8 { - margin-top: 8 * $default-spacer; -} - -.mb-1 { - margin-bottom: $default-spacer; -} - -.mb-2 { - margin-bottom: 2 * $default-spacer; -} - -.mb-3 { - margin-bottom: 3 * $default-spacer; -} - -.mb-4 { - margin-bottom: 4 * $default-spacer; -} - -.mb-8 { - margin-bottom: 8 * $default-spacer; -} - -.pt-1 { - padding-top: $default-spacer; -} - -.pt-2 { - padding-top: 2 * $default-spacer; -} - -.pt-3 { - padding-top: 3 * $default-spacer; -} - -.pt-4 { - padding-top: 4 * $default-spacer; -} - -.pt-8 { - padding-top: 8 * $default-spacer; -} - -.pb-1 { - padding-bottom: $default-spacer; -} - -.pb-2 { - padding-bottom: 2 * $default-spacer; -} - -.pb-3 { - padding-bottom: 3 * $default-spacer; -} - -.pb-4 { - padding-bottom: 4 * $default-spacer; -} - -.pb-8 { - padding-bottom: 8 * $default-spacer; +@each $modifier, $properties in $directions { + @each $step in $steps { + @each $property in $properties { + .m#{$modifier}-#{$step} { + #{$property}: $step * $default-spacer; + } + } + } } diff --git a/app/controllers/administrateurs/dossier_submitted_messages_controller.rb b/app/controllers/administrateurs/dossier_submitted_messages_controller.rb new file mode 100644 index 000000000..21cc6bb77 --- /dev/null +++ b/app/controllers/administrateurs/dossier_submitted_messages_controller.rb @@ -0,0 +1,45 @@ +module Administrateurs + class DossierSubmittedMessagesController < AdministrateurController + before_action :retrieve_procedure + + def edit + @dossier_submitted_message = build_dossier_submitted_message + end + + def update + @dossier_submitted_message = build_dossier_submitted_message(dossier_submitted_message_params) + + if @dossier_submitted_message.save + redirect_to admin_procedure_path(@procedure), flash: { notice: "Les informations de fin de dépot ont bien été sauvegardées." } + else + flash.alert = "Impossible de sauvegarder les informations de fin de dépot, veuillez ré-essayer." + render :edit, status: 400 + end + end + + def create + @dossier_submitted_message = build_dossier_submitted_message(dossier_submitted_message_params) + if @dossier_submitted_message.save + redirect_to admin_procedure_path(@procedure), flash: { notice: "Les informations de fin de dépot ont bien été sauvegardées." } + else + flash.alert = "Impossible de sauvegarder les informations de \"fin de dépot\", veuillez ré-essayer." + render :edit, status: 400 + end + end + + private + + # for now, only works on active revision no matter the procedure_revision_policy + def build_dossier_submitted_message(attributes = {}) + dossier_submitted_message = @procedure.active_revision.dossier_submitted_message || @procedure.active_revision.build_dossier_submitted_message + + dossier_submitted_message.attributes = attributes unless attributes.empty? + dossier_submitted_message + end + + def dossier_submitted_message_params + params.require(:dossier_submitted_message) + .permit(:message_on_submit_by_usager) + end + end +end diff --git a/app/controllers/champs/siret_controller.rb b/app/controllers/champs/siret_controller.rb index fff8d2e97..0d790704a 100644 --- a/app/controllers/champs/siret_controller.rb +++ b/app/controllers/champs/siret_controller.rb @@ -17,7 +17,7 @@ class Champs::SiretController < ApplicationController begin etablissement = find_etablissement_with_siret - rescue APIEntreprise::API::Error::RequestFailed, APIEntreprise::API::Error::ServiceUnavailable + rescue APIEntreprise::API::Error::RequestFailed, APIEntreprise::API::Error::BadGateway, APIEntreprise::API::Error::TimedOut, APIEntreprise::API::Error::ServiceUnavailable # i18n-tasks-use t('errors.messages.siret_network_error') return siret_error(:network_error) end diff --git a/app/controllers/experts/avis_controller.rb b/app/controllers/experts/avis_controller.rb index c856fe2eb..393a18fae 100644 --- a/app/controllers/experts/avis_controller.rb +++ b/app/controllers/experts/avis_controller.rb @@ -17,7 +17,8 @@ module Experts end def procedure - @procedure = Procedure.find(params[:procedure_id]) + @procedure = current_expert.procedures.find_by(id: params[:procedure_id]) + redirect_to(expert_all_avis_path, flash: { alert: "Vous n’avez pas accès à cette démarche." }) and return unless @procedure expert_avis = current_expert.avis.includes(:dossier).not_hidden_by_administration.where(dossiers: { groupe_instructeur: GroupeInstructeur.where(procedure: @procedure.id) }) @avis_a_donner = expert_avis.without_answer @avis_donnes = expert_avis.with_answer @@ -156,7 +157,8 @@ module Experts end def set_avis_and_dossier - @avis = Avis.find(params[:id]) + @avis = current_expert.avis.find_by(id: params[:id]) + redirect_to(expert_all_avis_path, flash: { alert: "Vous n’avez pas accès à cet avis." }) and return unless @avis @dossier = @avis.dossier end diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index 2b059e156..89a665419 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -106,7 +106,7 @@ module Users sanitized_siret = siret_model.siret begin etablissement = APIEntrepriseService.create_etablissement(@dossier, sanitized_siret, current_user.id) - rescue APIEntreprise::API::Error::RequestFailed, APIEntreprise::API::Error::BadGateway, APIEntreprise::API::Error::TimedOut + rescue APIEntreprise::API::Error::RequestFailed, APIEntreprise::API::Error::BadGateway, APIEntreprise::API::Error::TimedOut, APIEntreprise::API::Error::ServiceUnavailable return render_siret_error(t('errors.messages.siret_network_error')) end if etablissement.nil? diff --git a/app/javascript/components/ComboMultiple.jsx b/app/javascript/components/ComboMultiple.jsx index f2eb93275..7938518a0 100644 --- a/app/javascript/components/ComboMultiple.jsx +++ b/app/javascript/components/ComboMultiple.jsx @@ -26,6 +26,19 @@ import { useDeferredSubmit, useHiddenField } from './shared/hooks'; const Context = createContext(); +const optionValueByLabel = (values, options, label) => { + const maybeOption = values.includes(label) + ? [label, label] + : options.find(([optionLabel]) => optionLabel == label); + return maybeOption ? maybeOption[1] : undefined; +}; +const optionLabelByValue = (values, options, value) => { + const maybeOption = values.includes(value) + ? [value, value] + : options.find(([, optionValue]) => optionValue == value); + return maybeOption ? maybeOption[0] : undefined; +}; + function ComboMultiple({ options, id, @@ -40,9 +53,6 @@ function ComboMultiple({ invariant(id || label, 'ComboMultiple: `id` or a `label` are required'); invariant(group, 'ComboMultiple: `group` is required'); - if (!Array.isArray(options[0])) { - options = options.filter((o) => o).map((o) => [o, o]); - } const inputRef = useRef(); const [term, setTerm] = useState(''); const [selections, setSelections] = useState(selected); @@ -51,25 +61,22 @@ function ComboMultiple({ const removedLabelledby = `${inputId}-remove`; const selectedLabelledby = `${inputId}-selected`; - const optionValueByLabel = (label) => { - const maybeOption = newValues.includes(label) - ? [label, label] - : options.find(([optionLabel]) => optionLabel == label); - return maybeOption ? maybeOption[1] : undefined; - }; - const optionLabelByValue = (value) => { - const maybeOption = newValues.includes(value) - ? [value, value] - : options.find(([, optionValue]) => optionValue == value); - return maybeOption ? maybeOption[0] : undefined; - }; - + const optionsWithLabels = useMemo( + () => + Array.isArray(options[0]) + ? options + : options.filter((o) => o).map((o) => [o, o]), + [options] + ); const extraOptions = useMemo( () => - acceptNewValues && term && term.length > 2 && !optionLabelByValue(term) + acceptNewValues && + term && + term.length > 2 && + !optionLabelByValue(newValues, optionsWithLabels, term) ? [[term, term]] : [], - [acceptNewValues, term, newValues.join(',')] + [acceptNewValues, term, optionsWithLabels, newValues] ); const results = useMemo( () => @@ -77,12 +84,12 @@ function ComboMultiple({ ...extraOptions, ...(term ? matchSorter( - options.filter(([label]) => !label.startsWith('--')), + optionsWithLabels.filter(([label]) => !label.startsWith('--')), term ) - : options) + : optionsWithLabels) ].filter(([, value]) => !selections.includes(value)), - [term, selections.join(','), newValues.join(',')] + [term, selections, extraOptions, optionsWithLabels] ); const [, setHiddenFieldValue, hiddenField] = useHiddenField(group, name); const awaitFormSubmit = useDeferredSubmit(hiddenField); @@ -100,7 +107,7 @@ function ComboMultiple({ }; const onSelect = (value) => { - const maybeValue = [...extraOptions, ...options].find( + const maybeValue = [...extraOptions, ...optionsWithLabels].find( ([val]) => val == value ); const selectedValue = maybeValue && maybeValue[1]; @@ -128,7 +135,7 @@ function ComboMultiple({ }; const onRemove = (label) => { - const optionValue = optionValueByLabel(label); + const optionValue = optionValueByLabel(newValues, options, label); if (optionValue) { saveSelection((selections) => selections.filter((value) => value != optionValue) @@ -149,7 +156,9 @@ function ComboMultiple({ ) { if ( term && - [...extraOptions, ...options].map(([label]) => label).includes(term) + [...extraOptions, ...optionsWithLabels] + .map(([label]) => label) + .includes(term) ) { event.preventDefault(); onSelect(term); @@ -172,7 +181,9 @@ function ComboMultiple({ const onBlur = () => { const shouldSelect = term && - [...extraOptions, ...options].map(([label]) => label).includes(term); + [...extraOptions, ...optionsWithLabels] + .map(([label]) => label) + .includes(term); awaitFormSubmit(() => { if (shouldSelect) { @@ -199,7 +210,7 @@ function ComboMultiple({ ))} diff --git a/app/javascript/components/ComboSearch.tsx b/app/javascript/components/ComboSearch.tsx index 4e322c453..9bdedc397 100644 --- a/app/javascript/components/ComboSearch.tsx +++ b/app/javascript/components/ComboSearch.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useRef } from 'react'; +import React, { useState, useRef, ChangeEventHandler } from 'react'; import { useDebounce } from 'use-debounce'; import { useQuery } from 'react-query'; import { @@ -68,7 +68,7 @@ function ComboSearch({ const [, value, label] = transformResult(result); return label ?? value; }; - const setExternalValueAndId = useCallback((label: string) => { + const setExternalValueAndId = (label: string) => { const { key, value, result } = resultsMap.current[label]; if (onChange) { onChange(value, result); @@ -76,36 +76,35 @@ function ComboSearch({ setExternalId(key); setExternalValue(value); } - }, []); + }; const awaitFormSubmit = useDeferredSubmit(hiddenField); - const handleOnChange = useCallback( - ({ target: { value } }) => { - setValue(value); - if (!value) { - if (onChange) { - onChange(null); - } else { - setExternalId(''); - setExternalValue(''); - } - } else if (value.length >= minimumInputLength) { - setSearchTerm(value.trim()); - if (allowInputValues) { - setExternalId(''); - setExternalValue(value); - } + const handleOnChange: ChangeEventHandler = ({ + target: { value } + }) => { + setValue(value); + if (!value) { + if (onChange) { + onChange(null); + } else { + setExternalId(''); + setExternalValue(''); } - }, - [minimumInputLength] - ); + } else if (value.length >= minimumInputLength) { + setSearchTerm(value.trim()); + if (allowInputValues) { + setExternalId(''); + setExternalValue(value); + } + } + }; - const handleOnSelect = useCallback((value: string) => { + const handleOnSelect = (value: string) => { setExternalValueAndId(value); setValue(value); setSearchTerm(''); awaitFormSubmit.done(); - }, []); + }; const { isSuccess, data } = useQuery( [scope, debouncedSearchTerm, scopeExtra], @@ -117,14 +116,14 @@ function ComboSearch({ const results = isSuccess && data ? transformResults(debouncedSearchTerm, data) : []; - const onBlur = useCallback(() => { + const onBlur = () => { if (!allowInputValues && isSuccess && results[0]) { const label = getLabel(results[0]); awaitFormSubmit(() => { handleOnSelect(label); }); } - }, [data]); + }; return ( diff --git a/app/javascript/components/MapEditor/components/CadastreLayer.tsx b/app/javascript/components/MapEditor/components/CadastreLayer.tsx index 4ebb14523..f2f0b7086 100644 --- a/app/javascript/components/MapEditor/components/CadastreLayer.tsx +++ b/app/javascript/components/MapEditor/components/CadastreLayer.tsx @@ -28,35 +28,41 @@ export function CadastreLayer({ const map = useMapLibre(); const selectedCadastresRef = useRef(new Set()); - const highlightFeature = useCallback((cid: string, highlight: boolean) => { - if (highlight) { - selectedCadastresRef.current.add(cid); - } else { - selectedCadastresRef.current.delete(cid); - } - if (selectedCadastresRef.current.size == 0) { - map.setFilter('parcelle-highlighted', ['in', 'id', '']); - } else { - map.setFilter('parcelle-highlighted', [ - 'in', - 'id', - ...selectedCadastresRef.current - ]); - } - }, []); + const highlightFeature = useCallback( + (cid: string, highlight: boolean) => { + if (highlight) { + selectedCadastresRef.current.add(cid); + } else { + selectedCadastresRef.current.delete(cid); + } + if (selectedCadastresRef.current.size == 0) { + map.setFilter('parcelle-highlighted', ['in', 'id', '']); + } else { + map.setFilter('parcelle-highlighted', [ + 'in', + 'id', + ...selectedCadastresRef.current + ]); + } + }, + [map] + ); - const hoverFeature = useCallback((feature: Feature, hover: boolean) => { - if (!selectedCadastresRef.current.has(feature.properties?.id)) { - map.setFeatureState( - { - source: 'cadastre', - sourceLayer: 'parcelles', - id: String(feature.id) - }, - { hover } - ); - } - }, []); + const hoverFeature = useCallback( + (feature: Feature, hover: boolean) => { + if (!selectedCadastresRef.current.has(feature.properties?.id)) { + map.setFeatureState( + { + source: 'cadastre', + sourceLayer: 'parcelles', + id: String(feature.id) + }, + { hover } + ); + } + }, + [map] + ); useCadastres(featureCollection, { hoverFeature, diff --git a/app/javascript/components/MapEditor/components/DrawLayer.tsx b/app/javascript/components/MapEditor/components/DrawLayer.tsx index f5292da9a..9bdeb3a2a 100644 --- a/app/javascript/components/MapEditor/components/DrawLayer.tsx +++ b/app/javascript/components/MapEditor/components/DrawLayer.tsx @@ -48,6 +48,8 @@ export function DrawLayer({ trash: true } }); + // We use mapbox-draw plugin with maplibre. They are compatible but types are not. + // eslint-disable-next-line @typescript-eslint/no-explicit-any map.addControl(draw as any, 'top-left'); draw.set( filterFeatureCollection(featureCollection, SOURCE_SELECTION_UTILISATEUR) @@ -64,11 +66,15 @@ export function DrawLayer({ return () => { if (drawRef.current) { + // We use mapbox-draw plugin with maplibre. They are compatible but types are not. + // eslint-disable-next-line @typescript-eslint/no-explicit-any map.removeControl(drawRef.current as any); drawRef.current = null; } }; - }, [enabled]); + // We only want to rerender draw layer on component mount or when the layer is toggled. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map, enabled]); const onSetId = useCallback(({ detail }) => { drawRef.current?.setFeatureProperty(detail.lid, 'id', detail.id); @@ -167,7 +173,9 @@ function useExternalEvents( useEffect(() => { fitBounds(featureCollection.bbox as LngLatBoundsLike); - }, []); + // We only want to zoom on bbox on component mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fitBounds]); useEvent('map:feature:focus', onFeatureFocus); useEvent('map:feature:create', onFeatureCreate); diff --git a/app/javascript/components/MapReader/components/GeoJSONLayer.tsx b/app/javascript/components/MapReader/components/GeoJSONLayer.tsx index 956a71fc4..f7161d00d 100644 --- a/app/javascript/components/MapReader/components/GeoJSONLayer.tsx +++ b/app/javascript/components/MapReader/components/GeoJSONLayer.tsx @@ -44,13 +44,13 @@ export function GeoJSONLayer({ popup.remove(); } }, - [popup] + [map, popup] ); const onMouseLeave = useCallback(() => { map.getCanvas().style.cursor = ''; popup.remove(); - }, [popup]); + }, [map, popup]); useExternalEvents(featureCollection); @@ -99,17 +99,22 @@ export function GeoJSONLayer({ function useExternalEvents(featureCollection: FeatureCollection) { const fitBounds = useFitBounds(); - const onFeatureFocus = useCallback(({ detail }) => { - const { id } = detail; - const feature = findFeature(featureCollection, id); - if (feature) { - fitBounds(getBounds(feature.geometry)); - } - }, []); + const onFeatureFocus = useCallback( + ({ detail }) => { + const { id } = detail; + const feature = findFeature(featureCollection, id); + if (feature) { + fitBounds(getBounds(feature.geometry)); + } + }, + [featureCollection, fitBounds] + ); useEffect(() => { fitBounds(featureCollection.bbox as LngLatBoundsLike); - }, []); + // We only want to zoom on bbox on component mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fitBounds]); useEvent('map:feature:focus', onFeatureFocus); } @@ -139,7 +144,7 @@ function LineStringLayer({ type: 'line', paint: lineStringSelectionLine }); - }, []); + }, [map, layerId, sourceId, feature]); useMapEvent('mouseenter', onMouseEnter, layerId); useMapEvent('mouseleave', onMouseLeave, layerId); @@ -172,7 +177,7 @@ function PointLayer({ type: 'circle', paint: pointSelectionCircle }); - }, []); + }, [map, layerId, sourceId, feature]); useMapEvent('mouseenter', onMouseEnter, layerId); useMapEvent('mouseleave', onMouseLeave, layerId); @@ -212,7 +217,7 @@ function PolygonLayer({ type: 'fill', paint: polygonSelectionFill }); - }, []); + }, [map, layerId, lineLayerId, sourceId, feature]); useMapEvent('mouseenter', onMouseEnter, layerId); useMapEvent('mouseleave', onMouseLeave, layerId); diff --git a/app/javascript/components/shared/FlashMessage.tsx b/app/javascript/components/shared/FlashMessage.tsx index 6a092ff60..413eae83a 100644 --- a/app/javascript/components/shared/FlashMessage.tsx +++ b/app/javascript/components/shared/FlashMessage.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { createPortal } from 'react-dom'; +import invariant from 'tiny-invariant'; export function FlashMessage({ message, @@ -12,11 +13,13 @@ export function FlashMessage({ sticky?: boolean; fixed?: boolean; }) { + const element = document.getElementById('flash_messages'); + invariant(element, 'Flash messages root element not found'); return createPortal(
{message}
, - document.getElementById('flash_messages')! + element ); } diff --git a/app/javascript/components/shared/maplibre/MapLibre.tsx b/app/javascript/components/shared/maplibre/MapLibre.tsx index b123733b2..2b439abfa 100644 --- a/app/javascript/components/shared/maplibre/MapLibre.tsx +++ b/app/javascript/components/shared/maplibre/MapLibre.tsx @@ -5,7 +5,8 @@ import React, { useEffect, useMemo, ReactNode, - createContext + createContext, + useCallback } from 'react'; import maplibre, { Map, Style, NavigationControl } from 'maplibre-gl'; @@ -37,11 +38,14 @@ export function MapLibre({ children, header, footer, layers }: MapLibreProps) { const containerRef = useRef(null); const [map, setMap] = useState(); - const onStyleChange = (style: Style) => { - if (map) { - map.setStyle(style); - } - }; + const onStyleChange = useCallback( + (style: Style) => { + if (map) { + map.setStyle(style); + } + }, + [map] + ); const { style, ...mapStyleProps } = useStyle(layers, onStyleChange); useEffect(() => { @@ -56,7 +60,7 @@ export function MapLibre({ children, header, footer, layers }: MapLibreProps) { setMap(map); }); } - }, []); + }, [map, style, isSupported]); if (!isSupported) { return ( diff --git a/app/javascript/components/shared/maplibre/hooks.ts b/app/javascript/components/shared/maplibre/hooks.ts index dc6aaef97..56a59ddf3 100644 --- a/app/javascript/components/shared/maplibre/hooks.ts +++ b/app/javascript/components/shared/maplibre/hooks.ts @@ -12,16 +12,22 @@ import { useMapLibre } from './MapLibre'; export function useFitBounds() { const map = useMapLibre(); - return useCallback((bbox: LngLatBoundsLike) => { - map.fitBounds(bbox, { padding: 100 }); - }, []); + return useCallback( + (bbox: LngLatBoundsLike) => { + map.fitBounds(bbox, { padding: 100 }); + }, + [map] + ); } export function useFlyTo() { const map = useMapLibre(); - return useCallback((zoom: number, center: [number, number]) => { - map.flyTo({ zoom, center }); - }, []); + return useCallback( + (zoom: number, center: [number, number]) => { + map.flyTo({ zoom, center }); + }, + [map] + ); } export function useEvent(eventName: string, callback: EventListener) { @@ -44,12 +50,16 @@ export function useMapEvent( const map = useMapLibre(); return useEffect(() => { if (target) { + // event typing is hard + // eslint-disable-next-line @typescript-eslint/no-explicit-any map.on(eventName as keyof MapLayerEventType, target, callback as any); } else { map.on(eventName, callback); } return () => { if (target) { + // event typing is hard + // eslint-disable-next-line @typescript-eslint/no-explicit-any map.off(eventName as keyof MapLayerEventType, target, callback as any); } else { map.off(eventName, callback); @@ -104,7 +114,7 @@ export function useStyle( [styleId, enabledLayers] ); - useEffect(() => onStyleChange(style), [style]); + useEffect(() => onStyleChange(style), [onStyleChange, style]); return { style, layers, setStyle, setLayerEnabled, setLayerOpacity }; } diff --git a/app/javascript/components/shared/maplibre/styles/layers/ortho.ts b/app/javascript/components/shared/maplibre/styles/layers/ortho.ts index 5423d46ce..41d70cbcd 100644 --- a/app/javascript/components/shared/maplibre/styles/layers/ortho.ts +++ b/app/javascript/components/shared/maplibre/styles/layers/ortho.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import type { AnyLayer } from 'maplibre-gl'; const layers: AnyLayer[] = [ diff --git a/app/javascript/components/shared/maplibre/styles/layers/vector.ts b/app/javascript/components/shared/maplibre/styles/layers/vector.ts index b91bf9cb3..b96800c2c 100644 --- a/app/javascript/components/shared/maplibre/styles/layers/vector.ts +++ b/app/javascript/components/shared/maplibre/styles/layers/vector.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import type { AnyLayer } from 'maplibre-gl'; const layers: AnyLayer[] = [ diff --git a/app/javascript/components/shared/queryClient.ts b/app/javascript/components/shared/queryClient.ts index 14164f831..8c7d8804b 100644 --- a/app/javascript/components/shared/queryClient.ts +++ b/app/javascript/components/shared/queryClient.ts @@ -2,6 +2,17 @@ import { QueryClient, QueryFunction } from 'react-query'; import { getJSON, isNumeric } from '@utils'; import { matchSorter } from 'match-sorter'; +type Gon = { + gon: { + autocomplete?: { + api_geo_url?: string; + api_adresse_url?: string; + api_education_url?: string; + }; + }; +}; +declare const window: Window & typeof globalThis & Gon; + const API_EDUCATION_QUERY_LIMIT = 5; const API_GEO_QUERY_LIMIT = 5; const API_ADRESSE_QUERY_LIMIT = 5; @@ -16,7 +27,7 @@ const API_ADRESSE_QUERY_LIMIT = 5; const API_GEO_COMMUNES_QUERY_LIMIT = 60; const { api_geo_url, api_adresse_url, api_education_url } = - (window as any).gon.autocomplete || {}; + window.gon.autocomplete || {}; type QueryKey = readonly [ scope: string, @@ -70,8 +81,9 @@ const defaultQueryFn: QueryFunction = async ({ } throw new Error(`Error fetching from "${scope}" API`); }); - (promise as any).cancel = () => controller && controller.abort(); - return promise; + return Object.assign(promise, { + cancel: () => controller && controller.abort() + }); }; let paysCache: { label: string }[]; @@ -85,6 +97,8 @@ async function getPays(): Promise<{ label: string }[]> { export const queryClient = new QueryClient({ defaultOptions: { queries: { + // we don't really care about global queryFn type + // eslint-disable-next-line @typescript-eslint/no-explicit-any queryFn: defaultQueryFn as any } } diff --git a/app/javascript/shared/utils.ts b/app/javascript/shared/utils.ts index 4cc346ada..0882f088c 100644 --- a/app/javascript/shared/utils.ts +++ b/app/javascript/shared/utils.ts @@ -88,6 +88,15 @@ export function ajax(options: Rails.AjaxOptions) { }); } +class ResponseError extends Error { + response: Response; + + constructor(response: Response) { + super(String(response.statusText || response.status)); + this.response = response; + } +} + export function getJSON(url: string, data: unknown, method = 'GET') { const { query, ...options } = fetchOptions(data, method); @@ -98,9 +107,7 @@ export function getJSON(url: string, data: unknown, method = 'GET') { } return response.json(); } - const error = new Error(String(response.statusText || response.status)); - (error as any).response = response; - throw error; + throw new ResponseError(response); }); } @@ -125,8 +132,9 @@ export function on( ); } -export function isNumeric(n: string) { - return !isNaN(parseFloat(n)) && isFinite(n as any as number); +export function isNumeric(s: string) { + const n = parseFloat(s); + return !isNaN(n) && isFinite(n); } function offset(element: HTMLElement) { diff --git a/app/models/champs/cnaf_champ.rb b/app/models/champs/cnaf_champ.rb index fc3c10744..e8d2d38d6 100644 --- a/app/models/champs/cnaf_champ.rb +++ b/app/models/champs/cnaf_champ.rb @@ -6,6 +6,7 @@ # data :jsonb # fetch_external_data_exceptions :string is an Array # private :boolean default(FALSE), not null +# rebased_at :datetime # row :integer # type :string # value :string diff --git a/app/models/concerns/dossier_rebase_concern.rb b/app/models/concerns/dossier_rebase_concern.rb index 1117b7ebe..bdfb04186 100644 --- a/app/models/concerns/dossier_rebase_concern.rb +++ b/app/models/concerns/dossier_rebase_concern.rb @@ -2,19 +2,57 @@ module DossierRebaseConcern extend ActiveSupport::Concern def rebase! - if brouillon? && revision != procedure.published_revision + if can_rebase? transaction do rebase end end end + def can_rebase? + revision != procedure.published_revision && + (brouillon? || accepted_en_construction_changes? || accepted_en_instruction_changes?) + end + + def pending_changes + revision.compare(procedure.published_revision) + end + private + def accepted_en_construction_changes? + en_construction? && pending_changes.all? { |change| accepted_en_construction_change?(change) } + end + + def accepted_en_instruction_changes? + en_instruction? && pending_changes.all? { |change| accepted_en_instruction_change?(change) } + end + + def accepted_en_construction_change?(change) + if change[:model] == :attestation_template || change[:op] == :move || change[:op] == :remove + true + elsif change[:op] == :update + case change[:attribute] + when :carte_layers + true + when :mandatory + change[:from] && !change[:to] + else + false + end + else + false + end + end + + def accepted_en_instruction_change?(change) + change[:model] == :attestation_template + end + def rebase attachments_to_purge = [] geo_areas_to_delete = [] - changes_by_type_de_champ = revision.compare(procedure.published_revision) + changes_by_type_de_champ = pending_changes .filter { |change| change[:model] == :type_de_champ } .group_by { |change| change[:stable_id] } @@ -51,7 +89,9 @@ module DossierRebaseConcern when :drop_down_options update[:value] = nil when :carte_layers - geo_areas_to_delete += champ.geo_areas + if change[:from].include?(:cadastres) && !change[:to].include?(:cadastres) + geo_areas_to_delete += champ.cadastres + end end update[:rebased_at] = Time.zone.now end diff --git a/app/models/dossier_submitted_message.rb b/app/models/dossier_submitted_message.rb new file mode 100644 index 000000000..01238804c --- /dev/null +++ b/app/models/dossier_submitted_message.rb @@ -0,0 +1,12 @@ +# == Schema Information +# +# Table name: dossier_submitted_messages +# +# id :bigint not null, primary key +# message_on_submit_by_usager :string +# created_at :datetime not null +# updated_at :datetime not null +# +class DossierSubmittedMessage < ApplicationRecord + has_many :revisions, class_name: 'ProcedureRevision', inverse_of: :dossier_submitted_message, dependent: :nullify +end diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 06741df9b..d3be28e47 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -18,7 +18,6 @@ # description :string # direction :string # duree_conservation_dossiers_dans_ds :integer -# duree_conservation_dossiers_hors_ds :integer # durees_conservation_required :boolean default(TRUE) # encrypted_api_particulier_token :string # euro_flag :boolean default(FALSE) @@ -80,6 +79,10 @@ class Procedure < ApplicationRecord has_one :draft_attestation_template, through: :draft_revision, source: :attestation_template has_one :published_attestation_template, through: :published_revision, source: :attestation_template + has_one :published_dossier_submitted_message, dependent: :destroy, through: :published_revision, source: :dossier_submitted_message + has_one :draft_dossier_submitted_message, dependent: :destroy, through: :draft_revision, source: :dossier_submitted_message + has_many :dossier_submitted_messages, through: :revisions, source: :dossier_submitted_message + has_many :experts_procedures, dependent: :destroy has_many :experts, through: :experts_procedures @@ -92,6 +95,10 @@ class Procedure < ApplicationRecord belongs_to :service, optional: true belongs_to :zone, optional: true + def active_dossier_submitted_message + published_dossier_submitted_message || draft_dossier_submitted_message + end + def active_revision brouillon? ? draft_revision : published_revision end @@ -441,7 +448,8 @@ class Procedure < ApplicationRecord revision_types_de_champ_private: { type_de_champ: :types_de_champ }, - attestation_template: [] + attestation_template: [], + dossier_submitted_message: [] } } include_list[:groupe_instructeurs] = :instructeurs if !is_different_admin @@ -738,9 +746,9 @@ class Procedure < ApplicationRecord def publish_revision! update!(draft_revision: create_new_revision, published_revision: draft_revision) published_revision.touch(:published_at) - dossiers.state_brouillon.find_each do |dossier| - DossierRebaseJob.perform_later(dossier) - end + dossiers + .state_not_termine + .find_each { |dossier| DossierRebaseJob.perform_later(dossier) } end def cnaf_enabled? diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb index b1fd83078..458dd0c38 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -7,12 +7,14 @@ # created_at :datetime not null # updated_at :datetime not null # attestation_template_id :bigint -# procedure_id :bigint not null +# dossier_submitted_message_id :bigint +# procedure_id :bigint not null # class ProcedureRevision < ApplicationRecord self.implicit_order_column = :created_at belongs_to :procedure, -> { with_discarded }, inverse_of: :revisions, optional: false belongs_to :attestation_template, inverse_of: :revisions, optional: true, dependent: :destroy + belongs_to :dossier_submitted_message, inverse_of: :revisions, optional: true, dependent: :destroy has_many :dossiers, inverse_of: :revision, foreign_key: :revision_id diff --git a/app/models/zone.rb b/app/models/zone.rb index 9a699c428..2d5e57c57 100644 --- a/app/models/zone.rb +++ b/app/models/zone.rb @@ -3,7 +3,7 @@ # Table name: zones # # id :bigint not null, primary key -# acronym :string +# acronym :string not null # label :string # created_at :datetime not null # updated_at :datetime not null diff --git a/app/views/administrateurs/dossier_submitted_messages/_informations.html.haml b/app/views/administrateurs/dossier_submitted_messages/_informations.html.haml new file mode 100644 index 000000000..f5ae76910 --- /dev/null +++ b/app/views/administrateurs/dossier_submitted_messages/_informations.html.haml @@ -0,0 +1,3 @@ += f.label :message_on_submit_by_usager do + Message affiché après l'envoie du dossier += f.text_area :message_on_submit_by_usager, placeholder: "Merci votre dossier sera traité dans les plus bref delais" diff --git a/app/views/administrateurs/dossier_submitted_messages/edit.html.haml b/app/views/administrateurs/dossier_submitted_messages/edit.html.haml new file mode 100644 index 000000000..633073303 --- /dev/null +++ b/app/views/administrateurs/dossier_submitted_messages/edit.html.haml @@ -0,0 +1,34 @@ +- content_for(:root_class, 'scroll-margins-for-sticky-footer') + += render partial: 'administrateurs/breadcrumbs', + locals: { steps: [link_to('Démarches', admin_procedures_path), + link_to(@procedure.libelle, admin_procedure_path(@procedure)), + 'Fin de dépot'] } + +.procedure-form + .procedure-form__columns.container + = form_for @dossier_submitted_message, + url: url_for({ controller: 'administrateurs/dossier_submitted_messages', action: :update, id: @procedure.id }), + html: { class: 'form procedure-form__column--form' } do |f| + + %h1.page-title + Fin du dépot + %p.notice + L'utilisateur se vera afficher ce message une fois le dossier envoyé + + = render partial: 'administrateurs/dossier_submitted_messages/informations', locals: { f: f } + + + .procedure-form__actions + .actions-left + = f.submit 'Enregistrer', class: 'button primary send' + + .procedure-form__column--preview + %h3 + .procedure-form__preview-title + Aperçu + .notice + Cet aperçu est mis à jour après chaque sauvegarde. + + .procedure-preview + = render partial: 'users/dossiers/merci', locals: { procedure: @procedure, dossier: nil} diff --git a/app/views/administrateurs/procedures/show.html.haml b/app/views/administrateurs/procedures/show.html.haml index 04dc0bb42..05b43bcbe 100644 --- a/app/views/administrateurs/procedures/show.html.haml +++ b/app/views/administrateurs/procedures/show.html.haml @@ -234,3 +234,18 @@ %p.card-admin-title MonAvis %p.card-admin-subtitle Avis des usagers sur votre démarche %p.button Modifier + + = link_to edit_admin_procedure_dossier_submitted_message_path(@procedure), class: 'card-admin' do + - if @procedure.active_dossier_submitted_message.present? + %div + %span.icon.accept + %p.card-admin-status-accept Validé + - else + %div + %span.icon.clock + %p.card-admin-status-todo À configurer + %div + %p.card-admin-title Fin de dépot + %p.card-admin-subtitle Orienter l'usager suite à l'envoie de son dossier + %p.button Modifier + diff --git a/app/views/agent_connect/agent/index.html.haml b/app/views/agent_connect/agent/index.html.haml index c3533aeb5..7199d8732 100644 --- a/app/views/agent_connect/agent/index.html.haml +++ b/app/views/agent_connect/agent/index.html.haml @@ -1,8 +1,45 @@ - content_for(:title, t('.cta')) -.container - %h1.mt-2.mb-2= t('.connect') +#agentconnect + .two-columns + .columns-container + .column.agent-intro + %h1.mt-2.mb-2.agent= t('.you_are_an_agent') + .box= t('.in_progress_html') - %p= t('.intro_html', app_name: APPLICATION_NAME) + .center.mt-2 + %span.citizen= t('.you_are_a_citizen') + %br + %br + = link_to t('.citizen_page'), new_user_session_path, class: "button expend secondary" - = link_to t('.cta'), agent_connect_login_path, class: "france-connect-agent-login-button" + .column + = t('.connect_html') + + = link_to t('.cta'), agent_connect_login_path, class: "france-connect-agent-login-button" + .france-connect-help-link + = link_to t('.whats_agentconnect'), 'https://agentconnect.gouv.fr/', class: 'link', target: '_blank', target: "_blank", rel: "noopener", class: "link" + + .france-connect-login-separator + = t('views.shared.france_connect_login.separator') + + #session-new.auth-form.sign-in-form + = form_for User.new, url: user_session_path, html: { class: "form" } do |f| + = f.label :email, t('.pro_email') + = f.text_field :email, type: :email, autocomplete: 'username', autofocus: true + + = f.label :password, t('views.users.sessions.new.password', min_length: PASSWORD_MIN_LENGTH) + = f.password_field :password, autocomplete: 'current-password' + + .auth-options + .flex-no-shrink + = f.check_box :remember_me + = f.label :remember_me, t('views.users.sessions.new.remember_me'), class: 'remember-me' + + .text-right + = link_to t('views.users.sessions.new.reset_password'), new_user_password_path, class: "link" + + = f.submit t('views.users.sessions.new.connection'), class: "button large primary expand" + +- content_for :footer do + = render partial: 'users/dossiers/index_footer' diff --git a/app/views/dossier_mailer/notify_new_answer.html.haml b/app/views/dossier_mailer/notify_new_answer.html.haml index 6b05e9455..8a4a78929 100644 --- a/app/views/dossier_mailer/notify_new_answer.html.haml +++ b/app/views/dossier_mailer/notify_new_answer.html.haml @@ -3,16 +3,19 @@ %p= t(:hello, scope: [:views, :shared, :greetings]) -- if !@dossier.brouillon? - %p= t('.body_html', libelle_demarche: @dossier.procedure.libelle) - %p= t('.link') - = round_button(t('.access_message'), messagerie_dossier_url(@dossier), :primary) -- else +- if @dossier.brouillon? %p= t('.body_draft_html', libelle_demarche: @dossier.procedure.libelle) %p{ style: "padding: 8px; color: #333333; background-color: #EEEEEE; font-size: 14px;" } = @body - %p= t('.contact') + - if @service&.email.present? + %p= t('.contact_html', email: @service.email) + - else + %p= t('.contact_no_email') = round_button(t('.access_file'), dossier_url(@dossier), :primary) +- else + %p= t('.body_html', libelle_demarche: @dossier.procedure.libelle) + %p= t('.link') + = round_button(t('.access_message'), messagerie_dossier_url(@dossier), :primary) = render 'layouts/mailers/signature', service: @service diff --git a/app/views/layouts/_header.haml b/app/views/layouts/_header.haml index 988c61f60..7566f1a88 100644 --- a/app/views/layouts/_header.haml +++ b/app/views/layouts/_header.haml @@ -62,7 +62,7 @@ %li = render partial: 'layouts/account_dropdown', locals: { nav_bar_profile: nav_bar_profile } - - elsif request.path != new_user_session_path + - elsif (request.path != new_user_session_path && request.path != agent_connect_path) - if request.path == new_user_registration_path %li = t('views.shared.account.already_user_question') diff --git a/app/views/layouts/commencer/_no_procedure.html.haml b/app/views/layouts/commencer/_no_procedure.html.haml index 50037d4ad..d8be7b199 100644 --- a/app/views/layouts/commencer/_no_procedure.html.haml +++ b/app/views/layouts/commencer/_no_procedure.html.haml @@ -7,3 +7,8 @@ = t('.line2') %br = t('.line3') + %hr + %span.small-simple= t('.are_you_new', app_name: APPLICATION_NAME.gsub("-","‑")).html_safe + %br + %br + = link_to t('views.users.sessions.new.find_procedure'), COMMENT_TROUVER_MA_DEMARCHE_URL, target: "_blank", class: "button expend secondary" diff --git a/app/views/shared/_france_connect_login.html.haml b/app/views/shared/_france_connect_login.html.haml index 9b1b9794c..4279ff2d7 100644 --- a/app/views/shared/_france_connect_login.html.haml +++ b/app/views/shared/_france_connect_login.html.haml @@ -1,6 +1,6 @@ - if FranceConnectService.enabled? .france-connect-login - %h2 + %h2.important-header = t('views.shared.france_connect_login.title') %p = t('views.shared.france_connect_login.description') diff --git a/app/views/users/dossiers/_merci.html.haml b/app/views/users/dossiers/_merci.html.haml new file mode 100644 index 000000000..a6ba51366 --- /dev/null +++ b/app/views/users/dossiers/_merci.html.haml @@ -0,0 +1,26 @@ +.merci.text-center.mb-7 + .container + = image_tag('user/envoi-dossier.svg', alt: '', class: 'mt-8') + %h1.mt-4.mb-3.mx-0= t('views.users.dossiers.merci.thanks') + %p.send.m-2.text-lg + = t('views.users.dossiers.merci.dossier_send_l1') + %strong= procedure.libelle + = t('views.users.dossiers.merci.dossier_send_l2') + %p.m-2 + = t('views.users.dossiers.merci.dossier_acces_l1') + %strong= t('views.users.dossiers.merci.dossier_acces_l2') + %p.m-2 + = t('views.users.dossiers.merci.dossier_edit_l1') + - if !dossier&.read_only? + %strong= t('views.users.dossiers.merci.dossier_edit_l2') + = t('views.users.dossiers.merci.dossier_edit_l3') + %strong= t('views.users.dossiers.merci.dossier_edit_l4') + - if procedure.active_dossier_submitted_message + %p.m-2= procedure.active_dossier_submitted_message.message_on_submit_by_usager + + .flex.column.align-center + = link_to t('views.users.dossiers.merci.acces_dossier'), dossier ? dossier_path(dossier) : "#dossier" , class: 'button large primary mt-4' + = link_to t('views.users.dossiers.merci.submit_dossier'), procedure_lien(procedure), class: 'mt-4' + + .monavis + != procedure.monavis_embed diff --git a/app/views/users/dossiers/merci.html.haml b/app/views/users/dossiers/merci.html.haml index 9b689d21c..063ce5a4e 100644 --- a/app/views/users/dossiers/merci.html.haml +++ b/app/views/users/dossiers/merci.html.haml @@ -3,26 +3,4 @@ - content_for :footer do = render partial: "users/procedure_footer", locals: { procedure: @dossier.procedure, dossier: @dossier } -.merci - .container - = image_tag('user/envoi-dossier.svg', alt: '') - %h1= t('views.users.dossiers.merci.thanks') - %p.send - = t('views.users.dossiers.merci.dossier_send_l1') - %b= @dossier.procedure.libelle - = t('views.users.dossiers.merci.dossier_send_l2') - %p - = t('views.users.dossiers.merci.dossier_acces_l1') - %b= t('views.users.dossiers.merci.dossier_acces_l2') - %p - = t('views.users.dossiers.merci.dossier_edit_l1') - - if !@dossier.read_only? - %b= t('views.users.dossiers.merci.dossier_edit_l2') - = t('views.users.dossiers.merci.dossier_edit_l3') - %b= t('views.users.dossiers.merci.dossier_edit_l4') - - .flex.column.align-center - = link_to t('views.users.dossiers.merci.acces_dossier'), dossier_path(@dossier), class: 'button large primary' - = link_to t('views.users.dossiers.merci.submit_dossier'), procedure_lien(@dossier.procedure) - .monavis - != @dossier.procedure.monavis_embed += render partial: 'users/dossiers/merci', locals: { dossier: @dossier, procedure: @dossier.procedure} diff --git a/app/views/users/sessions/new.html.haml b/app/views/users/sessions/new.html.haml index 5e72e1f9b..42811868a 100644 --- a/app/views/users/sessions/new.html.haml +++ b/app/views/users/sessions/new.html.haml @@ -1,6 +1,6 @@ = content_for(:page_id, 'auth') -.auth-form.sign-in-form +#session-new.auth-form.sign-in-form = form_for resource, url: user_session_path, html: { class: "form" } do |f| %h1.huge-title= t('views.users.sessions.new.sign_in') @@ -23,15 +23,10 @@ = f.submit t('views.users.sessions.new.connection'), class: "button large primary expand" - .france-connect-login-separator - = t('views.shared.france_connect_login.separator') - if AgentConnectService.enabled? + .france-connect-login-separator + = t('views.shared.france_connect_login.separator') .center - %p.mb-2= t('views.users.sessions.new.instructor_or_admin') - = link_to t('views.users.sessions.new.connect_with_agent_connect'), agent_connect_path - %hr - %p.center - %span= t('views.users.sessions.new.are_you_new', app_name: APPLICATION_NAME.gsub("-","‑")).html_safe - %br - %br - = link_to t('views.users.sessions.new.find_procedure'), COMMENT_TROUVER_MA_DEMARCHE_URL, target: "_blank", class: "button expend secondary" + %h2.important-header= t('views.users.sessions.new.state_civil_servant') + %br + = link_to t('views.users.sessions.new.connect_with_agent_connect'), agent_connect_path, class: "button expend secondary" diff --git a/bin/setup b/bin/setup index ca172bca2..f07a41744 100755 --- a/bin/setup +++ b/bin/setup @@ -18,6 +18,7 @@ FileUtils.chdir APP_ROOT do system('bundle check') || system!('bundle install') # Install JavaScript dependencies + system! 'node --version' system! 'bin/yarn install' puts "\n== Updating webdrivers ==" diff --git a/bin/update b/bin/update index 1f9877a1a..f5478864d 100755 --- a/bin/update +++ b/bin/update @@ -15,7 +15,9 @@ FileUtils.chdir APP_ROOT do puts '== Installing dependencies ==' system! 'gem install bundler --conservative' system('bundle check') || system!('bundle install') + system! 'node --version' system! 'bin/yarn install' + system! 'bin/yarn clean' puts "\n== Updating webdrivers ==" system! 'RAILS_ENV=test bin/rails webdrivers:chromedriver:update' diff --git a/config/brakeman.ignore b/config/brakeman.ignore index d1cd809da..ac201cf2d 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -1,5 +1,46 @@ { "ignored_warnings": [ + { + "warning_type": "Cross-Site Scripting", + "warning_code": 2, + "fingerprint": "1b805585567775589825c0eda58cb84c074fc760d0a7afb101c023a51427f2b5", + "check_name": "CrossSiteScripting", + "message": "Unescaped model attribute", + "file": "app/views/users/dossiers/_merci.html.haml", + "line": 26, + "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", + "code": "current_user.dossiers.includes(:procedure).find(params[:id]).procedure.monavis_embed", + "render_path": [ + { + "type": "controller", + "class": "Users::DossiersController", + "method": "merci", + "line": 196, + "file": "app/controllers/users/dossiers_controller.rb", + "rendered": { + "name": "users/dossiers/merci", + "file": "app/views/users/dossiers/merci.html.haml" + } + }, + { + "type": "template", + "name": "users/dossiers/merci", + "line": 6, + "file": "app/views/users/dossiers/merci.html.haml", + "rendered": { + "name": "users/dossiers/_merci", + "file": "app/views/users/dossiers/_merci.html.haml" + } + } + ], + "location": { + "type": "template", + "template": "users/dossiers/_merci" + }, + "user_input": "current_user.dossiers.includes(:procedure)", + "confidence": "Weak", + "note": "" + }, { "warning_type": "Cross-Site Scripting", "warning_code": 2, @@ -38,7 +79,7 @@ "check_name": "SQL", "message": "Possible SQL injection", "file": "app/models/traitement.rb", - "line": 51, + "line": 52, "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", "code": "ActiveRecord::Base.connection.execute(\"select date_trunc('month', r1.processed_at::TIMESTAMPTZ AT TIME ZONE '#{Time.zone.formatted_offset}'::INTERVAL) as month, count(r1.processed_at)\\nfrom (#{Traitement.select(\"max(traitements.processed_at) as processed_at\").termine.where(:dossier => Dossier.state_termine.where(:groupe_instructeur => groupe_instructeurs)).group(:dossier_id).to_sql}) as r1\\ngroup by date_trunc('month', r1.processed_at::TIMESTAMPTZ AT TIME ZONE '#{Time.zone.formatted_offset}'::INTERVAL)\\norder by month desc\\n\")", "render_path": null, @@ -51,37 +92,6 @@ "confidence": "Medium", "note": "" }, - { - "warning_type": "Cross-Site Scripting", - "warning_code": 2, - "fingerprint": "483ae8c038244eb3ed709e89846335e2c8ff6579260348ec31d3d03d1c94ad64", - "check_name": "CrossSiteScripting", - "message": "Unescaped model attribute", - "file": "app/views/users/dossiers/merci.html.haml", - "line": 28, - "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", - "code": "current_user.dossiers.includes(:procedure).find(params[:id]).procedure.monavis_embed", - "render_path": [ - { - "type": "controller", - "class": "Users::DossiersController", - "method": "merci", - "line": 195, - "file": "app/controllers/users/dossiers_controller.rb", - "rendered": { - "name": "users/dossiers/merci", - "file": "app/views/users/dossiers/merci.html.haml" - } - } - ], - "location": { - "type": "template", - "template": "users/dossiers/merci" - }, - "user_input": "current_user.dossiers.includes(:procedure)", - "confidence": "Weak", - "note": "" - }, { "warning_type": "SQL Injection", "warning_code": 0, @@ -102,26 +112,6 @@ "confidence": "Medium", "note": "The table and column are escaped, which should make this safe" }, - { - "warning_type": "SQL Injection", - "warning_code": 0, - "fingerprint": "c0f93612a68c32da58f327e0b5fa33dd42fd8beb2984cf023338c5aadbbdacca", - "check_name": "SQL", - "message": "Possible SQL injection", - "file": "app/models/stat.rb", - "line": 83, - "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", - "code": "association.where(date_attribute => ((3.months.ago.beginning_of_month..max_date))).group(\"DATE_TRUNC('month', #{date_attribute}::TIMESTAMPTZ AT TIME ZONE '#{Time.zone.formatted_offset}'::INTERVAL)\")", - "render_path": null, - "location": { - "type": "method", - "class": "Stat", - "method": "last_four_months_hash" - }, - "user_input": "date_attribute", - "confidence": "Weak", - "note": "" - }, { "warning_type": "Redirect", "warning_code": 18, @@ -129,7 +119,7 @@ "check_name": "Redirect", "message": "Possible unprotected redirect", "file": "app/controllers/instructeurs/procedures_controller.rb", - "line": 195, + "line": 202, "link": "https://brakemanscanner.org/docs/warning_types/redirect/", "code": "redirect_to(Export.find_or_create_export(params[:export_format], (params[:time_span_type] or \"everything\"), current_instructeur.groupe_instructeurs.where(:procedure => procedure)).file.service_url)", "render_path": null, @@ -141,28 +131,8 @@ "user_input": "Export.find_or_create_export(params[:export_format], (params[:time_span_type] or \"everything\"), current_instructeur.groupe_instructeurs.where(:procedure => procedure)).file.service_url", "confidence": "High", "note": "" - }, - { - "warning_type": "SQL Injection", - "warning_code": 0, - "fingerprint": "f2bb9bc6a56e44ab36ee18152c657395841cff354baed0a302b8d18650551529", - "check_name": "SQL", - "message": "Possible SQL injection", - "file": "app/models/stat.rb", - "line": 97, - "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", - "code": "association.where(\"#{date_attribute} < ?\", max_date).group(\"DATE_TRUNC('month', #{date_attribute}::TIMESTAMPTZ AT TIME ZONE '#{Time.zone.formatted_offset}'::INTERVAL)\")", - "render_path": null, - "location": { - "type": "method", - "class": "Stat", - "method": "cumulative_hash" - }, - "user_input": "date_attribute", - "confidence": "Weak", - "note": "" } ], - "updated": "2021-12-01 17:39:08 -1000", + "updated": "2022-02-22 15:46:39 +0100", "brakeman_version": "5.1.1" } diff --git a/config/environments/test.rb b/config/environments/test.rb index 540394959..cfe9eb30b 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -66,8 +66,8 @@ Rails.application.configure do protocol: :http } - # Use Content-Security-Policy-Report-Only headers - config.content_security_policy_report_only = true + # Disallow all connections to external domains during tests + config.content_security_policy_report_only = false config.active_job.queue_adapter = :test config.active_storage.service = :test diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index c798a1bed..6d162df29 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -5,22 +5,20 @@ # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy Rails.application.config.content_security_policy do |policy| - # Whitelist image images_whitelist = ["*.openstreetmap.org", "*.cloud.ovh.net", "*"] images_whitelist << URI(DS_PROXY_URL).host if DS_PROXY_URL.present? images_whitelist << URI(MATOMO_IFRAME_URL).host if MATOMO_IFRAME_URL.present? policy.img_src(:self, :data, :blob, *images_whitelist) - # Whitelist JS: nous, sendinblue et matomo - # miniprofiler et nous avons quelques boutons inline :( + # Javascript: allow us, SendInBlue and Matomo. + # We need unsafe_inline because miniprofiler and us have some inline buttons :( scripts_whitelist = ["*.sendinblue.com", "*.crisp.chat", "crisp.chat", "*.sibautomation.com", "sibautomation.com", "cdn.jsdelivr.net", "maxcdn.bootstrapcdn.com", "code.jquery.com"] scripts_whitelist << URI(MATOMO_IFRAME_URL).host if MATOMO_IFRAME_URL.present? policy.script_src(:self, :unsafe_eval, :unsafe_inline, :blob, *scripts_whitelist) - # Pour les CSS, on a beaucoup de style inline et quelques balises