diff --git a/Gemfile b/Gemfile index 5f7056546..1fd7947eb 100644 --- a/Gemfile +++ b/Gemfile @@ -49,7 +49,7 @@ gem 'kaminari', '1.2.1' # Pagination gem 'listen' # Required by ActiveSupport::EventedFileUpdateChecker gem 'lograge' gem 'logstash-event' -gem 'mailjet' +gem 'mailjet', require: false gem 'openid_connect' gem 'pg' gem 'phonelib' diff --git a/app/assets/stylesheets/_typography.scss b/app/assets/stylesheets/_typography.scss index da7c458df..3113dfbaf 100644 --- a/app/assets/stylesheets/_typography.scss +++ b/app/assets/stylesheets/_typography.scss @@ -4,3 +4,9 @@ font-family: "Muli", system-ui, -apple-system, sans-serif; color: $black; } + +ol { + line-height: 28px; + list-style-type: decimal; + list-style-position: inside; +} diff --git a/app/assets/stylesheets/auth.scss b/app/assets/stylesheets/auth.scss index ea9cfeba4..4cc36d96e 100644 --- a/app/assets/stylesheets/auth.scss +++ b/app/assets/stylesheets/auth.scss @@ -10,7 +10,7 @@ // The procedure description can still be read from the /commencer // pages. @media (max-width: $two-columns-breakpoint) { - .procedure-description { + .procedure-preview { display: none; } } diff --git a/app/assets/stylesheets/flex.scss b/app/assets/stylesheets/flex.scss index 1a04cdda3..fc1cc0780 100644 --- a/app/assets/stylesheets/flex.scss +++ b/app/assets/stylesheets/flex.scss @@ -45,3 +45,7 @@ .flex-grow { flex-grow: 1; } + +.flex-no-shrink { + flex-shrink: 0; +} diff --git a/app/assets/stylesheets/link-sent.scss b/app/assets/stylesheets/link-sent.scss index 778d330b1..c22d6d6f4 100644 --- a/app/assets/stylesheets/link-sent.scss +++ b/app/assets/stylesheets/link-sent.scss @@ -5,26 +5,38 @@ padding-top: 2 * $default-padding; padding-bottom: 2 * $default-padding; text-align: center; - max-width: 600px; + max-width: 700px; - b { + section { + text-align: left; + margin: 3 * $default-padding auto; + } + + p, + ol { + margin-top: $default-padding; + margin-bottom: $default-padding; + } + + .link-sent-info { + color: #000000; + background-color: $yellow; + padding: 0 $default-padding; + border: 1px solid transparent; // prevent margin collapse of first paragraph + } + + .link-sent-help { + border-top: 1px solid $grey; + padding-top: $default-padding; + margin-bottom: $default-padding; + } + + .link-sent-help-title { font-weight: bold; } - p { - text-align: left; - margin: 6 * $default-spacer auto; - } - - p.mail { - color: #000000; - background-color: $yellow; - padding: $default-padding; - } - - p.help { - border-top: 1px solid $grey; - padding-top: 6 * $default-spacer; - margin-bottom: 2 * $default-spacer; + .link-sent-help-list { + list-style-position: outside; + padding-left: $default-padding; } } diff --git a/app/assets/stylesheets/procedure_admin.scss b/app/assets/stylesheets/procedure_admin.scss index 0227b3772..0ca1fb781 100644 --- a/app/assets/stylesheets/procedure_admin.scss +++ b/app/assets/stylesheets/procedure_admin.scss @@ -36,3 +36,13 @@ font-size: 20px; margin-bottom: 20px; } + +.admin-metadata { + margin-top: -8px; + margin-bottom: 8px; + + li { + font-size: 14px; + } + +} diff --git a/app/controllers/instructeurs/procedures_controller.rb b/app/controllers/instructeurs/procedures_controller.rb index d1c72bf0b..1ff1145c4 100644 --- a/app/controllers/instructeurs/procedures_controller.rb +++ b/app/controllers/instructeurs/procedures_controller.rb @@ -108,10 +108,10 @@ module Instructeurs @dossiers = @dossiers.where(id: filtered_sorted_paginated_ids) - @dossiers = procedure_presentation.eager_load_displayed_fields(@dossiers) - @dossiers = @dossiers.sort_by { |d| filtered_sorted_paginated_ids.index(d.id) } + @projected_dossiers = DossierProjectionService.project(filtered_sorted_paginated_ids, procedure_presentation.displayed_fields) + kaminarize(page, filtered_sorted_ids.count) assign_exports diff --git a/app/controllers/users/passwords_controller.rb b/app/controllers/users/passwords_controller.rb index 47d1c75e0..b756cc17e 100644 --- a/app/controllers/users/passwords_controller.rb +++ b/app/controllers/users/passwords_controller.rb @@ -33,6 +33,10 @@ class Users::PasswordsController < Devise::PasswordsController # super # end + def reset_link_sent + @email = params[:email] + end + # protected # def after_resetting_password_path_for(resource) @@ -74,4 +78,9 @@ class Users::PasswordsController < Devise::PasswordsController def password_params params.require(:user).permit(:reset_password_token, :password) end + + def after_sending_reset_password_instructions_path_for(resource_name) + flash.discard(:notice) + users_password_reset_link_sent_path(email: resource.email) + end end diff --git a/app/javascript/components/shared/queryClient.js b/app/javascript/components/shared/queryClient.js index 8d64f0689..a39b37fc9 100644 --- a/app/javascript/components/shared/queryClient.js +++ b/app/javascript/components/shared/queryClient.js @@ -19,6 +19,13 @@ function buildURL(scope, term) { return `${api_adresse_url}/search?q=${term}&limit=5`; } else if (scope === 'annuaire-education') { return `${api_education_url}/search?dataset=fr-en-annuaire-education&q=${term}&rows=5`; + } else if (scope === 'communes') { + if (isNumeric(term)) { + return `${api_geo_url}/communes?codePostal=${term}&limit=5`; + } + // Avoid hiding similar matches for precise queries (like "Sainte Marie") + const limit = term.length > 5 ? 10 : 5; + return `${api_geo_url}/communes?nom=${term}&boost=population&limit=${limit}`; } else if (isNumeric(term)) { const code = term.padStart(2, '0'); return `${api_geo_url}/${scope}?code=${code}&limit=5`; diff --git a/app/mailers/devise_user_mailer.rb b/app/mailers/devise_user_mailer.rb index 9377e9f10..bc548c166 100644 --- a/app/mailers/devise_user_mailer.rb +++ b/app/mailers/devise_user_mailer.rb @@ -1,6 +1,7 @@ # Preview all emails at http://localhost:3000/rails/mailers/devise_user_mailer class DeviseUserMailer < Devise::Mailer helper :application # gives access to all helpers defined within `application_helper`. + helper MailerHelper include Devise::Controllers::UrlHelpers # Optional. eg. `confirmation_url` layout 'mailers/layout' diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index daab7a86f..d64dff58f 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -90,10 +90,6 @@ class ProcedurePresentation < ApplicationRecord ] end - def displayed_fields_values(dossier) - displayed_fields.map { |field| get_value(dossier, field[TABLE], field[COLUMN]) } - end - def sorted_ids(dossiers, instructeur) table, column, order = sort.values_at(TABLE, COLUMN, 'order') @@ -178,25 +174,6 @@ class ProcedurePresentation < ApplicationRecord end.reduce(:&) end - def eager_load_displayed_fields(dossiers) - relations_to_include = displayed_fields - .pluck(TABLE) - .reject { |table| table == 'self' } - .map do |table| - case table - when TYPE_DE_CHAMP - { champs: :type_de_champ } - when TYPE_DE_CHAMP_PRIVATE - { champs_private: :type_de_champ } - else - table - end - end - .uniq - - dossiers.includes(relations_to_include) - end - def human_value_for_filter(filter) case filter[TABLE] when TYPE_DE_CHAMP, TYPE_DE_CHAMP_PRIVATE @@ -314,23 +291,6 @@ class ProcedurePresentation < ApplicationRecord end end - def get_value(dossier, table, column) - case table - when 'self' - dossier.send(column)&.strftime('%d/%m/%Y') - when 'user', 'individual', 'etablissement' - dossier.send(table)&.send(column) - when 'followers_instructeurs' - dossier.send(table)&.map { |g| g.send(column) }&.join(', ') - when TYPE_DE_CHAMP - dossier.champs.find { |c| c.stable_id == column.to_i }.to_s - when TYPE_DE_CHAMP_PRIVATE - dossier.champs_private.find { |c| c.stable_id == column.to_i }.to_s - when 'groupe_instructeur' - dossier.groupe_instructeur.label - end - end - def field_hash(label, table, column) { 'label' => label, diff --git a/app/services/dossier_projection_service.rb b/app/services/dossier_projection_service.rb new file mode 100644 index 000000000..a53905cec --- /dev/null +++ b/app/services/dossier_projection_service.rb @@ -0,0 +1,88 @@ +class DossierProjectionService + class DossierProjection < Struct.new(:dossier, :columns) + end + + TABLE = 'table' + COLUMN = 'column' + + # Returns [DossierProjection(dossier, columns)] ordered by dossiers_ids + # and the columns orderd by fields. + # + # It tries to be fast by using `pluck` (or at least `select`) + # to avoid deserializing entire records. + # + # It stores its intermediary queries results in an hash in the corresponding field. + # ex: field_email[:id_value_h] = { dossier_id_1: email_1, dossier_id_3: email_3 } + # + # Those hashes are needed because: + # - the order of the intermediary query results are unknown + # - some values can be missing (if a revision added or removed them) + def self.project(dossiers_ids, fields) + champ_fields, other_fields = fields + .partition { |f| ['type_de_champ', 'type_de_champ_private'].include?(f[TABLE]) } + + if champ_fields.present? + Champ + .includes(:type_de_champ) + .where( + # as querying the champs table is costly + # we fetch all the requested champs at once + types_de_champ: { stable_id: champ_fields.map { |f| f[COLUMN] } }, + dossier_id: dossiers_ids + ) + .select(:dossier_id, :value, :type_de_champ_id, :stable_id) # we cannot pluck :value, as we need the champ.to_s method + .group_by(&:stable_id) # the champs are redispatched to their respective fields + .map do |stable_id, champs| + field = champ_fields.find { |f| f[COLUMN] == stable_id.to_s } + field[:id_value_h] = champs.to_h { |c| [c.dossier_id, c.to_s] } + end + end + + other_fields.each do |field| + field[:id_value_h] = case field[TABLE] + when 'self' + Dossier + .where(id: dossiers_ids) + .pluck(:id, field[COLUMN].to_sym) + .to_h { |id, col| [id, col&.strftime('%d/%m/%Y')] } + when 'user' + Dossier + .joins(:user) + .where(id: dossiers_ids) + .pluck('dossiers.id, users.email') + .to_h + when 'individual' + Individual + .where(dossier_id: dossiers_ids) + .pluck(:dossier_id, field[COLUMN].to_sym) + .to_h + when 'etablissement' + Etablissement + .where(dossier_id: dossiers_ids) + .pluck(:dossier_id, field[COLUMN].to_sym) + .to_h + when 'groupe_instructeur' + Dossier + .joins(:groupe_instructeur) + .where(id: dossiers_ids) + .pluck('dossiers.id, groupe_instructeurs.label') + .to_h + when 'followers_instructeurs' + Follow + .active + .joins(instructeur: :user) + .where(dossier_id: dossiers_ids) + .pluck('dossier_id, users.email') + .group_by { |dossier_id, _| dossier_id } + .to_h { |dossier_id, dossier_id_emails| [dossier_id, dossier_id_emails.map { |_, email| email }&.join(', ')] } + end + end + + Dossier + .select(:id, :state, :archived) # the dossier object is needed in the view + .find(dossiers_ids) # keeps dossiers_ids order and raise exception if one is missing + .map do |dossier| + DossierProjection.new(dossier, fields.map { |f| f[:id_value_h][dossier.id] }) + end + end +end diff --git a/app/views/devise_mailer/reset_password_instructions.html.haml b/app/views/devise_mailer/reset_password_instructions.html.haml index 765ce037d..32afd3ee0 100644 --- a/app/views/devise_mailer/reset_password_instructions.html.haml +++ b/app/views/devise_mailer/reset_password_instructions.html.haml @@ -2,9 +2,9 @@ Bonjour, %p - Vous avez demandé à regénérer votre mot de passe sur #{APPLICATION_BASE_URL}. Pour ceci, merci de suivre le lien suivant : - %br - = link_to edit_password_url(@resource, reset_password_token: @token), edit_password_url(@resource, reset_password_token: @token) + Vous avez demandé à changer votre mot de passe sur #{APPLICATION_NAME}. Pour ceci, merci de cliquer sur le lien suivant : + += round_button 'Changer mon mot de passe', edit_password_url(@resource, reset_password_token: @token), :primary %p Si vous n'avez pas effectué une telle demande, merci d'ignorer cet email. Votre mot de passe ne sera pas changé. diff --git a/app/views/instructeurs/passwords/new.html.haml b/app/views/instructeurs/passwords/new.html.haml index cb55a2bf1..aa4533459 100644 --- a/app/views/instructeurs/passwords/new.html.haml +++ b/app/views/instructeurs/passwords/new.html.haml @@ -17,5 +17,5 @@ %br %br .actions - = f.submit 'Réinitialiser', class: 'btn btn-primary' + = f.submit 'Demander un nouveau mot de passe', class: 'button large expand primary' %br diff --git a/app/views/instructeurs/procedures/show.html.haml b/app/views/instructeurs/procedures/show.html.haml index a3ff15df1..90d95fcb9 100644 --- a/app/views/instructeurs/procedures/show.html.haml +++ b/app/views/instructeurs/procedures/show.html.haml @@ -129,25 +129,27 @@ = submit_tag "Enregistrer", class: 'button' %tbody - - @dossiers.each do |dossier| + - @projected_dossiers.each do |p| + - dossier = p.dossier + - path = instructeur_dossier_path(@procedure, dossier.id) + %tr %td.folder-col - = link_to(instructeur_dossier_path(@procedure, dossier), class: 'cell-link') do + %a.cell-link{ href: path } %span.icon.folder - if @not_archived_notifications_dossier_ids.include?(dossier.id) %span.notifications{ 'aria-label': 'notifications' } %td.number-col - = link_to(instructeur_dossier_path(@procedure, dossier), class: 'cell-link') do - = dossier.id + %a.cell-link{ href: path }= dossier.id - - @procedure_presentation.displayed_fields_values(dossier).each do |value| + - p.columns.each do |column| %td - = link_to(value, instructeur_dossier_path(@procedure, dossier), class: 'cell-link') + %a.cell-link{ href: path }= column %td.status-col - = link_to(instructeur_dossier_path(@procedure, dossier), class: 'cell-link') do - = status_badge(dossier.state) + %a.cell-link{ href: path }= status_badge(dossier.state) + %td.action-col.follow-col= render partial: 'dossier_actions', locals: { procedure: @procedure, dossier: dossier, dossier_is_followed: @followed_dossiers_id.include?(dossier.id) } = paginate @dossiers - else diff --git a/app/views/new_administrateur/_breadcrumbs.html.haml b/app/views/new_administrateur/_breadcrumbs.html.haml index b8dc5d94c..93353f9ee 100644 --- a/app/views/new_administrateur/_breadcrumbs.html.haml +++ b/app/views/new_administrateur/_breadcrumbs.html.haml @@ -1,8 +1,12 @@ .sub-header - .container.flex.justify-between.align-baseline + .container.flex.justify-between.align-baseline.column %ul.breadcrumbs - steps.each do |step| %li= step - if defined?(preview) && preview = link_to "Prévisualiser le formulaire", apercu_admin_procedure_path(@procedure), target: "_blank", rel: "noopener", class: 'button' = link_to "Continuer >", admin_procedure_path(@procedure), title: 'Vous pourrez revenir ici par la suite', class: 'button accepted' + - if defined?(metadatas) + %ul.admin-metadata + - metadatas.each do |metadata| + %li= metadata diff --git a/app/views/new_administrateur/procedures/_procedures_list.html.haml b/app/views/new_administrateur/procedures/_procedures_list.html.haml index 4f2678533..b01e0944f 100644 --- a/app/views/new_administrateur/procedures/_procedures_list.html.haml +++ b/app/views/new_administrateur/procedures/_procedures_list.html.haml @@ -6,7 +6,7 @@ .flex.column.ml-1 .card-title = link_to procedure.libelle, admin_procedure_path(procedure), style: 'color: black;' - = link_to(procedure_lien(procedure), procedure_lien(procedure), class: 'procedure-lien mb-1') + = link_to(procedure_lien(procedure), procedure_lien(procedure), class: 'mb-1') .admin-procedures-list-timestamps %p.notice N° #{procedure.id} diff --git a/app/views/new_administrateur/procedures/show.html.haml b/app/views/new_administrateur/procedures/show.html.haml index 25efba6bf..169a9ca30 100644 --- a/app/views/new_administrateur/procedures/show.html.haml +++ b/app/views/new_administrateur/procedures/show.html.haml @@ -1,6 +1,7 @@ = render partial: 'new_administrateur/breadcrumbs', locals: { steps: [link_to('Démarches', admin_procedures_path), - "#{@procedure.libelle} (crée le #{@procedure.created_at.strftime('%d/%m/%Y')})", "#{@procedure.close? ? "Close" : @procedure.locked? ? "Publiée" : "Brouillon"}"] } + "#{@procedure.libelle}", ], + metadatas: ["Créée le #{@procedure.created_at.strftime('%d/%m/%Y')} - n° #{@procedure.id}", "#{@procedure.close? ? "Close le #{@procedure.closed_at.strftime('%d/%m/%Y')}" : @procedure.locked? ? "Publiée - #{procedure_lien(@procedure)}" : "Brouillon"}"] } .container.procedure-admin-container = link_to apercu_admin_procedure_path(@procedure), class: 'button', id: "preview-procedure" do diff --git a/app/views/super_admins/passwords/new.html.haml b/app/views/super_admins/passwords/new.html.haml index e1c8a35d9..38dda419f 100644 --- a/app/views/super_admins/passwords/new.html.haml +++ b/app/views/super_admins/passwords/new.html.haml @@ -14,4 +14,4 @@ = f.label :email, 'Email' = f.email_field :email, autofocus: true - = f.submit 'Réinitialiser', class: 'button primary' + = f.submit 'Demander un nouveau mot de passe', class: 'button large expand primary' diff --git a/app/views/users/passwords/new.html.haml b/app/views/users/passwords/new.html.haml index e1c8a35d9..5513f1890 100644 --- a/app/views/users/passwords/new.html.haml +++ b/app/views/users/passwords/new.html.haml @@ -9,9 +9,11 @@ = form_for(resource, as: resource_name, url: password_path(resource_name), html: { class: 'form' }) do |f| - %h1 Mot de passe oublié + %h1= t('devise.passwords.new.forgot_your_password') + + %p.notice= t('views.passwords.new.send_me_reset_password_instructions') = f.label :email, 'Email' = f.email_field :email, autofocus: true - = f.submit 'Réinitialiser', class: 'button primary' + = f.submit 'Demander un nouveau mot de passe', class: 'button expand primary' diff --git a/app/views/users/passwords/reset_link_sent.html.haml b/app/views/users/passwords/reset_link_sent.html.haml new file mode 100644 index 000000000..a5771b1bb --- /dev/null +++ b/app/views/users/passwords/reset_link_sent.html.haml @@ -0,0 +1,32 @@ +- content_for(:title, t('views.users.passwords.reset_link_sent.title')) + +- content_for :footer do + = render partial: 'root/footer' + +#link-sent.container + = image_tag('user/confirmation-email.svg') + %h1 + = t('views.users.passwords.reset_link_sent.got_it') + %br + = t('views.users.passwords.reset_link_sent.open_your_mailbox') + + %section.link-sent-info + %p + = t('views.users.passwords.reset_link_sent.email_sent_html', email: @email) + %p + = t('views.users.passwords.reset_link_sent.click_link_to_reset_password') + %p + = t('views.users.shared.email_can_take_a_while_html') + + %section.link-sent-help + %h2.link-sent-help-title= t('views.users.passwords.reset_link_sent.no_mail') + %ol.link-sent-help-list + %li + = t('views.users.passwords.reset_link_sent.check_spams') + %li + = t('views.users.passwords.reset_link_sent.check_account', email: @email, application_name: APPLICATION_NAME) + - if FranceConnectService.enabled? + %li + = t('views.users.passwords.reset_link_sent.check_france_connect_html', href: france_connect_particulier_path) + %p + = t('views.users.shared.contact_us_if_any_trouble_html', href: contact_url) diff --git a/app/views/users/sessions/link_sent.html.haml b/app/views/users/sessions/link_sent.html.haml index 37961886c..52f463592 100644 --- a/app/views/users/sessions/link_sent.html.haml +++ b/app/views/users/sessions/link_sent.html.haml @@ -7,16 +7,14 @@ = image_tag('user/confirmation-email.svg') %h1 Encore une petite étape :) - %p.mail - Ouvrez votre boite email #{@email} puis cliquez sur le lien d'activation du message Connexion sécurisée à #{APPLICATION_NAME}. - %br - %br - Attention, ce message peut mettre jusqu'à 15 minutes pour arriver. + %section.link-sent-info + %p + Ouvrez votre boite email #{@email} puis cliquez sur le lien d’activation du message Connexion sécurisée à #{APPLICATION_NAME}. + %p + = t('views.users.shared.email_can_take_a_while') - %p.help - Si vous voyez cette page trop souvent, consultez notre aide : #{link_to FAQ_CONFIRMER_COMPTE_CHAQUE_CONNEXION_URL, FAQ_CONFIRMER_COMPTE_CHAQUE_CONNEXION_URL, target: '_blank', rel: 'noopener' } - %br - %br - En cas de difficultés, nous restons joignables - = succeed '.' do - = link_to("via ce formulaire", contact_admin_url) + %section.link-sent-help + %p + Si vous voyez cette page trop souvent, consultez notre aide : #{link_to FAQ_CONFIRMER_COMPTE_CHAQUE_CONNEXION_URL, FAQ_CONFIRMER_COMPTE_CHAQUE_CONNEXION_URL, target: '_blank', rel: 'noopener' } + %p + = t('views.users.shared.contact_us_if_any_trouble_html', href: contact_admin_url) diff --git a/app/views/users/sessions/new.html.haml b/app/views/users/sessions/new.html.haml index 5dc8a35d4..9ae063a8b 100644 --- a/app/views/users/sessions/new.html.haml +++ b/app/views/users/sessions/new.html.haml @@ -2,7 +2,7 @@ .auth-form.sign-in-form - = form_for User.new, url: user_session_path, html: { class: "form" } do |f| + = form_for resource, url: user_session_path, html: { class: "form" } do |f| %h1.huge-title= t('views.sessions.new.title') = render partial: 'shared/france_connect_login', locals: { url: france_connect_particulier_path } @@ -14,7 +14,7 @@ = f.password_field :password, autocomplete: 'current-password' .auth-options - %div + .flex-no-shrink = f.check_box :remember_me = f.label :remember_me, t('views.sessions.new.remember_me'), class: 'remember-me' diff --git a/config/initializers/mailjet.rb b/config/initializers/mailjet.rb index dafcb4084..a8789815b 100644 --- a/config/initializers/mailjet.rb +++ b/config/initializers/mailjet.rb @@ -1,5 +1,9 @@ -Mailjet.configure do |config| - config.api_key = Rails.application.secrets.mailjet[:api_key] - config.secret_key = Rails.application.secrets.mailjet[:secret_key] - config.default_from = CONTACT_EMAIL +ActiveSupport.on_load(:action_mailer) do + require 'mailjet' + + Mailjet.configure do |config| + config.api_key = Rails.application.secrets.mailjet[:api_key] + config.secret_key = Rails.application.secrets.mailjet[:secret_key] + config.default_from = CONTACT_EMAIL + end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 862534689..c7d2d3e5d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -49,7 +49,7 @@ en: new: title: Sign in email: Email address (name@site.com) - password: Password (minimum length %{min_length} characters) + password: Password remember_me: Remember me reset_password: Forgot password? connection: Sign in @@ -58,8 +58,11 @@ en: commencer: no_procedure: ligne1: A simple tool - ligne2: to manage dematerialized + ligne2: to manage dematerialized ligne3: administrative forms. + passwords: + new: + send_me_reset_password_instructions: "Fill-in your account's email, and we’ll send you instructions to reset your password." modal: publish: title: @@ -172,4 +175,4 @@ en: draft: zero: Draft one: Draft - other: Drafts \ No newline at end of file + other: Drafts diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 275d95b56..7d61251a6 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -39,9 +39,9 @@ fr: new: title: Connectez-vous email: Email (nom@site.com) - password: Mot de passe (%{min_length} caractères minimum) + password: Mot de passe remember_me: Se souvenir de moi - reset_password: Mot de passe oublié ? + reset_password: Mot de passe oublié ? connection: Se connecter are_you_new: Vous êtes nouveau sur %{app_name} ? find_procedure: Trouvez votre démarche @@ -50,6 +50,24 @@ fr: ligne1: Un outil simple ligne2: pour gérer les formulaires ligne3: administratifs dématérialisés. + passwords: + new: + send_me_reset_password_instructions: "Indiquez l’email de votre compte, et nous vous enverrons un lien pour créer un nouveau mot de passe." + users: + passwords: + reset_link_sent: + email_sent_html: "Nous vous avons envoyé un email à l’adresse %{email}." + click_link_to_reset_password: "Cliquez sur le lien contenu dans l’email pour changer votre mot de passe." + no_mail: "Vous n’avez pas reçu l’email ?" + check_spams: "Vérifiez la boite Indésirables ou Spam de votre boite email." + check_account: "Avez-vous bien créé un compte %{application_name} avec l’adresse %{email} ? Si aucun compte n’existe avec cette adresse, vous ne recevrez pas de message." + check_france_connect_html: "Vous êtes-vous connecté avec France Connect par le passé ? Dans ce cas essayez à nouveau avec France Connect." + got_it: "Bien reçu !" + open_your_mailbox: "Maintenant ouvrez votre boite email." + title: "Lien de réinitialisation du mot de passe envoyé" + shared: + email_can_take_a_while_html: "Attention, ce message peut mettre jusqu’à 15 minutes pour arriver." + contact_us_if_any_trouble_html: "En cas de difficultés, nous restons joignables via ce formulaire." modal: publish: title: @@ -185,4 +203,4 @@ fr: draft: zero: Brouillon one: Brouillon - other: Brouillons \ No newline at end of file + other: Brouillons diff --git a/config/routes.rb b/config/routes.rb index 20f39cb4e..bb953ffa4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -106,6 +106,7 @@ Rails.application.routes.draw do get '/users/no_procedure' => 'users/sessions#no_procedure' get 'connexion-par-jeton/:id' => 'users/sessions#sign_in_by_link', as: 'sign_in_by_link' get 'lien-envoye/:email' => 'users/sessions#link_sent', constraints: { email: /.*/ }, as: 'link_sent' + get '/users/password/reset-link-sent' => 'users/passwords#reset_link_sent' end devise_scope :administrateur do diff --git a/db/migrate/20210416160721_add_index_to_exercice_etablissement_id.rb b/db/migrate/20210416160721_add_index_to_exercice_etablissement_id.rb new file mode 100644 index 000000000..724a98979 --- /dev/null +++ b/db/migrate/20210416160721_add_index_to_exercice_etablissement_id.rb @@ -0,0 +1,5 @@ +class AddIndexToExerciceEtablissementId < ActiveRecord::Migration[6.1] + def change + add_index :exercices, :etablissement_id + end +end diff --git a/db/schema.rb b/db/schema.rb index d74b65981..c282337f9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_04_16_074049) do +ActiveRecord::Schema.define(version: 2021_04_16_160721) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -325,6 +325,7 @@ ActiveRecord::Schema.define(version: 2021_04_16_074049) do t.datetime "date_fin_exercice" t.datetime "created_at" t.datetime "updated_at" + t.index ["etablissement_id"], name: "index_exercices_on_etablissement_id" end create_table "experts", force: :cascade do |t| diff --git a/spec/controllers/users/passwords_controller_spec.rb b/spec/controllers/users/passwords_controller_spec.rb index f52ebc65f..8d3769cf5 100644 --- a/spec/controllers/users/passwords_controller_spec.rb +++ b/spec/controllers/users/passwords_controller_spec.rb @@ -38,4 +38,16 @@ describe Users::PasswordsController, type: :controller do end end end + + describe '#reset_link_sent' do + let(:email) { 'test@example.com' } + + it 'displays the page' do + get 'reset_link_sent', params: { email: email } + + expect(response).to have_http_status(:ok) + expect(response).to render_template('reset_link_sent') + expect(assigns(:email)).to eq email + end + end end diff --git a/spec/features/instructeurs/expert_spec.rb b/spec/features/instructeurs/expert_spec.rb index 1529421ae..d361b3cf3 100644 --- a/spec/features/instructeurs/expert_spec.rb +++ b/spec/features/instructeurs/expert_spec.rb @@ -42,7 +42,7 @@ feature 'Inviting an expert:', js: true do expect(emails_sent_to(expert2.email.to_s).size).to eq(1) invitation_email = open_email(expert.email.to_s) - avis = expert.avis.reload.last + avis = expert.avis.find_by(dossier: dossier) sign_up_link = sign_up_expert_avis_path(avis.dossier.procedure, avis, avis.expert.email) expect(invitation_email.body).to include(sign_up_link) end diff --git a/spec/features/sessions/sign_in_spec.rb b/spec/features/sessions/sign_in_spec.rb index 84361db70..e6888e6f8 100644 --- a/spec/features/sessions/sign_in_spec.rb +++ b/spec/features/sessions/sign_in_spec.rb @@ -6,8 +6,11 @@ feature 'Signin in:' do visit root_path click_on 'Connexion' - sign_in_with user.email, password + sign_in_with user.email, 'invalid-password' + expect(page).to have_content 'Courriel ou mot de passe incorrect.' + expect(page).to have_field('Email', with: user.email) + sign_in_with user.email, password expect(page).to have_current_path dossiers_path end diff --git a/spec/features/users/managing_password_spec.rb b/spec/features/users/managing_password_spec.rb index 96ee9bdd5..16d16fe10 100644 --- a/spec/features/users/managing_password_spec.rb +++ b/spec/features/users/managing_password_spec.rb @@ -6,14 +6,15 @@ feature 'Managing password:' do scenario 'a simple user can reset their password' do visit root_path click_on 'Connexion' - click_on 'Mot de passe oublié ?' + click_on 'Mot de passe oublié ?' expect(page).to have_current_path(new_user_password_path) fill_in 'Email', with: user.email perform_enqueued_jobs do - click_on 'Réinitialiser' + click_on 'Demander un nouveau mot de passe' end - expect(page).to have_content('Si votre courriel existe dans notre base de données, vous recevrez un lien vous permettant de récupérer votre mot de passe.') + expect(page).to have_text 'Nous vous avons envoyé un email' + expect(page).to have_text user.email click_reset_password_link_for user.email expect(page).to have_content 'Changement de mot de passe' @@ -33,14 +34,15 @@ feature 'Managing password:' do scenario 'an admin can reset their password' do visit root_path click_on 'Connexion' - click_on 'Mot de passe oublié ?' + click_on 'Mot de passe oublié ?' expect(page).to have_current_path(new_user_password_path) fill_in 'Email', with: user.email perform_enqueued_jobs do - click_on 'Réinitialiser' + click_on 'Demander un nouveau mot de passe' end - expect(page).to have_content('Si votre courriel existe dans notre base de données, vous recevrez un lien vous permettant de récupérer votre mot de passe.') + expect(page).to have_text 'Nous vous avons envoyé un email' + expect(page).to have_text user.email click_reset_password_link_for user.email diff --git a/spec/models/procedure_presentation_spec.rb b/spec/models/procedure_presentation_spec.rb index 1949fb4b9..815096316 100644 --- a/spec/models/procedure_presentation_spec.rb +++ b/spec/models/procedure_presentation_spec.rb @@ -125,126 +125,6 @@ describe ProcedurePresentation do it { expect(subject.displayed_fields_for_select).to eq([[["label1", "table1/column1"], ["label2", "table2/column2"]], ["user/email"]]) } end - describe '#get_value' do - let(:procedure_presentation) { create(:procedure_presentation, procedure: procedure, assign_to: assign_to, displayed_fields: [{ 'table' => table, 'column' => column }]) } - - subject { procedure_presentation.displayed_fields_values(dossier).first } - - context 'for self table' do - let(:table) { 'self' } - - context 'for created_at column' do - let(:column) { 'created_at' } - let(:dossier) { Timecop.freeze(Time.zone.local(1992, 3, 22)) { create(:dossier, procedure: procedure) } } - - it { is_expected.to eq('22/03/1992') } - end - - context 'for en_construction_at column' do - let(:column) { 'en_construction_at' } - let(:dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2018, 10, 17)) } - - it { is_expected.to eq('17/10/2018') } - end - - context 'for updated_at column' do - let(:column) { 'updated_at' } - let(:dossier) { create(:dossier, procedure: procedure) } - - before { dossier.touch(time: Time.zone.local(2018, 9, 25)) } - - it { is_expected.to eq('25/09/2018') } - end - end - - context 'for user table' do - let(:table) { 'user' } - let(:column) { 'email' } - - let(:dossier) { create(:dossier, procedure: procedure, user: create(:user, email: 'bla@yopmail.com')) } - - it { is_expected.to eq('bla@yopmail.com') } - end - - context 'for individual table' do - let(:table) { 'individual' } - let(:procedure) { create(:procedure, :for_individual, :with_type_de_champ, :with_type_de_champ_private) } - let(:dossier) { create(:dossier, procedure: procedure, individual: create(:individual, nom: 'Martin', prenom: 'Jacques', gender: 'M.')) } - - context 'for prenom column' do - let(:column) { 'prenom' } - - it { is_expected.to eq('Jacques') } - end - - context 'for nom column' do - let(:column) { 'nom' } - - it { is_expected.to eq('Martin') } - end - - context 'for gender column' do - let(:column) { 'gender' } - - it { is_expected.to eq('M.') } - end - end - - context 'for etablissement table' do - let(:table) { 'etablissement' } - let(:column) { 'code_postal' } # All other columns work the same, no extra test required - - let!(:dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, code_postal: '75008')) } - - it { is_expected.to eq('75008') } - end - - context 'for groupe_instructeur table' do - let(:table) { 'groupe_instructeur' } - let(:column) { 'label' } - - let!(:dossier) { create(:dossier, procedure: procedure) } - - it { is_expected.to eq('défaut') } - end - - context 'for followers_instructeurs table' do - let(:table) { 'followers_instructeurs' } - let(:column) { 'email' } - - let(:dossier) { create(:dossier, procedure: procedure) } - let!(:follow1) { create(:follow, dossier: dossier, instructeur: create(:instructeur, email: 'user1@host')) } - let!(:follow2) { create(:follow, dossier: dossier, instructeur: create(:instructeur, email: 'user2@host')) } - - it "return emails of followers instructeurs" do - emails_to_display = procedure_presentation.displayed_fields_values(dossier).first.split(', ').sort - expect(emails_to_display).to eq ["user1@host", "user2@host"] - end - end - - context 'for type_de_champ table' do - let(:table) { 'type_de_champ' } - let(:column) { procedure.types_de_champ.first.stable_id.to_s } - - let(:dossier) { create(:dossier, procedure: procedure) } - - before { dossier.champs.first.update(value: 'kale') } - - it { is_expected.to eq('kale') } - end - - context 'for type_de_champ_private table' do - let(:table) { 'type_de_champ_private' } - let(:column) { procedure.types_de_champ_private.first.stable_id.to_s } - - let(:dossier) { create(:dossier, procedure: procedure) } - - before { dossier.champs_private.first.update(value: 'quinoa') } - - it { is_expected.to eq('quinoa') } - end - end - describe '#sorted_ids' do let(:instructeur) { create(:instructeur) } let(:assign_to) { create(:assign_to, procedure: procedure, instructeur: instructeur) } @@ -755,136 +635,6 @@ describe ProcedurePresentation do end end - describe '#eager_load_displayed_fields' do - let(:procedure_presentation) { create(:procedure_presentation, procedure: procedure, assign_to: assign_to, displayed_fields: [{ 'table' => table, 'column' => column }]) } - let!(:dossier) { create(:dossier, :en_construction, procedure: procedure) } - let(:displayed_dossier) { procedure_presentation.eager_load_displayed_fields(procedure.dossiers).first } - - context 'for type de champ' do - let(:table) { 'type_de_champ' } - let(:column) { procedure.types_de_champ.first.stable_id.to_s } - - it 'preloads the champs relation' do - # Ideally, we would only preload the champs for the matching column - - expect(displayed_dossier.association(:champs)).to be_loaded - expect(displayed_dossier.association(:champs_private)).not_to be_loaded - expect(displayed_dossier.association(:user)).not_to be_loaded - expect(displayed_dossier.association(:individual)).not_to be_loaded - expect(displayed_dossier.association(:etablissement)).not_to be_loaded - expect(displayed_dossier.association(:followers_instructeurs)).not_to be_loaded - expect(displayed_dossier.association(:groupe_instructeur)).not_to be_loaded - end - end - - context 'for type de champ private' do - let(:table) { 'type_de_champ_private' } - let(:column) { procedure.types_de_champ_private.first.stable_id.to_s } - - it 'preloads the champs relation' do - # Ideally, we would only preload the champs for the matching column - - expect(displayed_dossier.association(:champs)).not_to be_loaded - expect(displayed_dossier.association(:champs_private)).to be_loaded - expect(displayed_dossier.association(:user)).not_to be_loaded - expect(displayed_dossier.association(:individual)).not_to be_loaded - expect(displayed_dossier.association(:etablissement)).not_to be_loaded - expect(displayed_dossier.association(:followers_instructeurs)).not_to be_loaded - expect(displayed_dossier.association(:groupe_instructeur)).not_to be_loaded - end - end - - context 'for user' do - let(:table) { 'user' } - let(:column) { 'email' } - - it 'preloads the user relation' do - expect(displayed_dossier.association(:champs)).not_to be_loaded - expect(displayed_dossier.association(:champs_private)).not_to be_loaded - expect(displayed_dossier.association(:user)).to be_loaded - expect(displayed_dossier.association(:individual)).not_to be_loaded - expect(displayed_dossier.association(:etablissement)).not_to be_loaded - expect(displayed_dossier.association(:followers_instructeurs)).not_to be_loaded - expect(displayed_dossier.association(:groupe_instructeur)).not_to be_loaded - end - end - - context 'for individual' do - let(:procedure) { create(:procedure, :for_individual, :with_type_de_champ, :with_type_de_champ_private) } - let(:table) { 'individual' } - let(:column) { 'nom' } - - it 'preloads the individual relation' do - expect(displayed_dossier.association(:champs)).not_to be_loaded - expect(displayed_dossier.association(:champs_private)).not_to be_loaded - expect(displayed_dossier.association(:user)).not_to be_loaded - expect(displayed_dossier.association(:individual)).to be_loaded - expect(displayed_dossier.association(:etablissement)).not_to be_loaded - expect(displayed_dossier.association(:followers_instructeurs)).not_to be_loaded - expect(displayed_dossier.association(:groupe_instructeur)).not_to be_loaded - end - end - - context 'for etablissement' do - let(:table) { 'etablissement' } - let(:column) { 'siret' } - - it 'preloads the etablissement relation' do - expect(displayed_dossier.association(:champs)).not_to be_loaded - expect(displayed_dossier.association(:champs_private)).not_to be_loaded - expect(displayed_dossier.association(:user)).not_to be_loaded - expect(displayed_dossier.association(:individual)).not_to be_loaded - expect(displayed_dossier.association(:etablissement)).to be_loaded - expect(displayed_dossier.association(:followers_instructeurs)).not_to be_loaded - expect(displayed_dossier.association(:groupe_instructeur)).not_to be_loaded - end - end - - context 'for followers_instructeurs' do - let(:table) { 'followers_instructeurs' } - let(:column) { 'email' } - - it 'preloads the followers_instructeurs relation' do - expect(displayed_dossier.association(:champs)).not_to be_loaded - expect(displayed_dossier.association(:champs_private)).not_to be_loaded - expect(displayed_dossier.association(:user)).not_to be_loaded - expect(displayed_dossier.association(:individual)).not_to be_loaded - expect(displayed_dossier.association(:etablissement)).not_to be_loaded - expect(displayed_dossier.association(:followers_instructeurs)).to be_loaded - expect(displayed_dossier.association(:groupe_instructeur)).not_to be_loaded - end - end - - context 'for groupe_instructeur' do - let(:table) { 'groupe_instructeur' } - let(:column) { 'label' } - - it 'preloads the groupe_instructeur relation' do - expect(displayed_dossier.association(:champs)).not_to be_loaded - expect(displayed_dossier.association(:champs_private)).not_to be_loaded - expect(displayed_dossier.association(:user)).not_to be_loaded - expect(displayed_dossier.association(:individual)).not_to be_loaded - expect(displayed_dossier.association(:etablissement)).not_to be_loaded - expect(displayed_dossier.association(:followers_instructeurs)).not_to be_loaded - expect(displayed_dossier.association(:groupe_instructeur)).to be_loaded - end - end - - context 'for self' do - let(:table) { 'self' } - let(:column) { 'created_at' } - - it 'does not preload anything' do - expect(displayed_dossier.association(:champs)).not_to be_loaded - expect(displayed_dossier.association(:champs_private)).not_to be_loaded - expect(displayed_dossier.association(:user)).not_to be_loaded - expect(displayed_dossier.association(:individual)).not_to be_loaded - expect(displayed_dossier.association(:etablissement)).not_to be_loaded - expect(displayed_dossier.association(:followers_instructeurs)).not_to be_loaded - end - end - end - describe "#human_value_for_filter" do let(:filters) { { "suivis" => [{ "label" => "label1", "table" => "type_de_champ", "column" => first_type_de_champ_id, "value" => "true" }] } } diff --git a/spec/services/dossier_projection_service_spec.rb b/spec/services/dossier_projection_service_spec.rb new file mode 100644 index 000000000..aef955f73 --- /dev/null +++ b/spec/services/dossier_projection_service_spec.rb @@ -0,0 +1,157 @@ +describe DossierProjectionService do + describe '#project' do + subject { described_class.project(dossiers_ids, fields) } + + context 'with multiple dossier' do + let!(:procedure) { create(:procedure, :with_type_de_champ) } + let!(:dossier_1) { create(:dossier, procedure: procedure) } + let!(:dossier_2) { create(:dossier, procedure: procedure) } + let!(:dossier_3) { create(:dossier, procedure: procedure) } + + let(:dossiers_ids) { [dossier_3.id, dossier_1.id, dossier_2.id] } + let(:fields) do + [ + { + "table" => "type_de_champ", + "column" => procedure.types_de_champ[0].stable_id.to_s + } + ] + end + + before do + dossier_1.champs.first.update(value: 'champ_1') + dossier_2.champs.first.update(value: 'champ_2') + dossier_3.champs.first.destroy + end + + let(:result) { subject } + + it 'respects the dossiers_ids order and returns nil for empty result' do + expect(result.length).to eq(3) + expect(result[0].dossier.id).to eq(dossier_3.id) + expect(result[1].dossier.id).to eq(dossier_1.id) + expect(result[2].dossier.id).to eq(dossier_2.id) + + expect(result[0].columns[0]).to be nil + expect(result[1].columns[0]).to eq('champ_1') + expect(result[2].columns[0]).to eq('champ_2') + end + end + + context 'attributes by attributes' do + let(:fields) { [{ "table" => table, "column" => column }] } + let(:dossiers_ids) { [dossier.id] } + + subject { super()[0].columns[0] } + + context 'for self table' do + let(:table) { 'self' } + + context 'for created_at column' do + let(:column) { 'created_at' } + let(:dossier) { Timecop.freeze(Time.zone.local(1992, 3, 22)) { create(:dossier) } } + + it { is_expected.to eq('22/03/1992') } + end + + context 'for en_construction_at column' do + let(:column) { 'en_construction_at' } + let(:dossier) { create(:dossier, :en_construction, en_construction_at: Time.zone.local(2018, 10, 17)) } + + it { is_expected.to eq('17/10/2018') } + end + + context 'for updated_at column' do + let(:column) { 'updated_at' } + let(:dossier) { create(:dossier) } + + before { dossier.touch(time: Time.zone.local(2018, 9, 25)) } + + it { is_expected.to eq('25/09/2018') } + end + end + + context 'for user table' do + let(:table) { 'user' } + let(:column) { 'email' } + + let(:dossier) { create(:dossier, user: create(:user, email: 'bla@yopmail.com')) } + + it { is_expected.to eq('bla@yopmail.com') } + end + + context 'for individual table' do + let(:table) { 'individual' } + let(:procedure) { create(:procedure, :for_individual, :with_type_de_champ, :with_type_de_champ_private) } + let(:dossier) { create(:dossier, procedure: procedure, individual: create(:individual, nom: 'Martin', prenom: 'Jacques', gender: 'M.')) } + + context 'for prenom column' do + let(:column) { 'prenom' } + + it { is_expected.to eq('Jacques') } + end + + context 'for nom column' do + let(:column) { 'nom' } + + it { is_expected.to eq('Martin') } + end + + context 'for gender column' do + let(:column) { 'gender' } + + it { is_expected.to eq('M.') } + end + end + + context 'for etablissement table' do + let(:table) { 'etablissement' } + let(:column) { 'code_postal' } # All other columns work the same, no extra test required + + let!(:dossier) { create(:dossier, etablissement: create(:etablissement, code_postal: '75008')) } + + it { is_expected.to eq('75008') } + end + + context 'for groupe_instructeur table' do + let(:table) { 'groupe_instructeur' } + let(:column) { 'label' } + + let!(:dossier) { create(:dossier) } + + it { is_expected.to eq('défaut') } + end + + context 'for followers_instructeurs table' do + let(:table) { 'followers_instructeurs' } + let(:column) { 'email' } + + let(:dossier) { create(:dossier) } + let!(:follow1) { create(:follow, dossier: dossier, instructeur: create(:instructeur, email: 'user1@host')) } + let!(:follow2) { create(:follow, dossier: dossier, instructeur: create(:instructeur, email: 'user2@host')) } + + it { is_expected.to eq "user1@host, user2@host" } + end + + context 'for type_de_champ table' do + let(:table) { 'type_de_champ' } + let(:dossier) { create(:dossier) } + let(:column) { dossier.procedure.types_de_champ.first.stable_id.to_s } + + before { dossier.champs.first.update(value: 'kale') } + + it { is_expected.to eq('kale') } + end + + context 'for type_de_champ_private table' do + let(:table) { 'type_de_champ_private' } + let(:dossier) { create(:dossier) } + let(:column) { dossier.procedure.types_de_champ_private.first.stable_id.to_s } + + before { dossier.champs_private.first.update(value: 'quinoa') } + + it { is_expected.to eq('quinoa') } + end + end + end +end diff --git a/spec/views/users/sessions/new.html.haml_spec.rb b/spec/views/users/sessions/new.html.haml_spec.rb index 9b3da052d..10a1181af 100644 --- a/spec/views/users/sessions/new.html.haml_spec.rb +++ b/spec/views/users/sessions/new.html.haml_spec.rb @@ -3,7 +3,7 @@ describe 'users/sessions/new.html.haml', type: :view do before(:each) do allow(view).to receive(:devise_mapping).and_return(Devise.mappings[:user]) - allow(view).to receive(:resource_name).and_return(:user) + allow(view).to receive(:resource).and_return(:user) end before do diff --git a/yarn.lock b/yarn.lock index 6cde2824a..59c13c45b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11617,9 +11617,9 @@ sshpk@^1.7.0: tweetnacl "~0.14.0" ssri@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.1.tgz#2a3c41b28dd45b62b63676ecb74001265ae9edd8" - integrity sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA== + version "6.0.2" + resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.2.tgz#157939134f20464e7301ddba3e90ffa8f7728ac5" + integrity sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q== dependencies: figgy-pudding "^3.5.1"