Merge pull request #2984 from betagouv/dev

2018-11-13-01
This commit is contained in:
LeSim 2018-11-13 11:36:16 +01:00 committed by GitHub
commit cc64ab64ec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 666 additions and 99 deletions

View file

@ -0,0 +1 @@
<svg width="81" height="86" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><g stroke-linecap="round" stroke-linejoin="round" stroke="#0069CC" stroke-width="2"><path d="M9.203 31.348L1.083 37h-.045L1 77.334C1 81.606 4.538 85 8.865 85h62.92c4.325 0 8.296-3.168 8.296-7.44V37m-.068.125L71 31"/><path d="M2.459 37.212l38.014 25.92 38.014-26.211M3.384 82.25L29.967 56m47.595 26.25L50.979 56"/></g><path d="M40.04 63.132L9.08 41.505V5.307a4 4 0 0 1 4-4H67a4 4 0 0 1 4 4v36.96L40.04 63.132z" stroke="#0069CC" stroke-width="2" fill="#EFF6FF" stroke-linecap="round" stroke-linejoin="round"/><path d="M40.87 45.53c-3.103 0-5.81-.63-8.12-1.89-2.31-1.26-4.09-3.022-5.337-5.285-1.249-2.263-1.873-4.877-1.873-7.84 0-3.197.665-5.973 1.995-8.33s3.156-4.165 5.477-5.425c2.322-1.26 4.953-1.89 7.893-1.89 3.057 0 5.682.59 7.875 1.767 2.193 1.179 3.856 2.76 4.987 4.743 1.132 1.983 1.698 4.165 1.698 6.545 0 2.147-.327 3.955-.98 5.425-.653 1.47-1.528 2.572-2.625 3.307a6.398 6.398 0 0 1-3.64 1.103c-1.26 0-2.263-.268-3.01-.805-.747-.537-1.19-1.283-1.33-2.24h-.07c-1.33 2.03-3.068 3.045-5.215 3.045-1.68 0-3.004-.566-3.973-1.698-.968-1.131-1.452-2.665-1.452-4.602 0-1.657.315-3.18.945-4.568.63-1.388 1.51-2.49 2.642-3.307 1.132-.817 2.421-1.225 3.868-1.225 1.003 0 1.89.227 2.66.682.77.456 1.295 1.068 1.575 1.838h.07l.385-2.17h3.255l-1.575 8.785c-.093.49-.14.992-.14 1.505 0 .747.157 1.29.472 1.627.316.339.811.508 1.488.508.537 0 1.08-.24 1.627-.718.549-.478 1.01-1.254 1.383-2.327s.56-2.462.56-4.165c0-1.937-.432-3.675-1.295-5.215s-2.158-2.765-3.885-3.675c-1.727-.91-3.827-1.365-6.3-1.365-2.31 0-4.38.507-6.213 1.522-1.831 1.016-3.272 2.491-4.322 4.428-1.05 1.937-1.575 4.235-1.575 6.895 0 2.45.484 4.59 1.452 6.422a10.232 10.232 0 0 0 4.165 4.253c1.809 1.003 3.961 1.505 6.458 1.505 1.843 0 3.424-.24 4.742-.718C46.931 41.5 47.917 40.84 48.57 40h4.235c-.957 1.68-2.467 3.022-4.533 4.025-2.065 1.003-4.532 1.505-7.402 1.505zm-1.575-10.395c1.027 0 1.913-.315 2.66-.945.747-.63 1.312-1.447 1.698-2.45a8.813 8.813 0 0 0 .577-3.185c0-1.167-.274-2.053-.822-2.66-.549-.607-1.301-.91-2.258-.91-.98 0-1.826.315-2.537.945-.712.63-1.255 1.44-1.628 2.433a8.624 8.624 0 0 0-.56 3.062c0 1.143.262 2.047.787 2.713.526.665 1.22.997 2.083.997z" fill="#0069CC"/></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -15,4 +15,5 @@ $light-green: lighten($green, 25%);
$dark-green: darken($green, 20%); $dark-green: darken($green, 20%);
$orange: #F28900; $orange: #F28900;
$orange-bg: lighten($orange, 35%); $orange-bg: lighten($orange, 35%);
$yellow: #FEF3B8;
$light-yellow: #FFFFDE; $light-yellow: #FFFFDE;

View file

@ -0,0 +1,64 @@
@import "colors";
@import "constants";
.devise-confirmations {
.one-column-centered {
max-width: 600px;
}
.confirmation-icon,
.confirmation-preamble,
.confirmation-instructions,
.confirmation-separator {
font-size: 1.15em;
margin-bottom: $default-padding * 3;
}
.confirmation-icon {
display: block;
margin-left: auto;
margin-right: auto;
}
.confirmation-instructions {
color: #000000;
background-color: $yellow;
margin-left: -15px;
margin-right: -15px;
padding: 15px 20px 17px 20px;
}
.confirmation-separator {
height: 1px;
margin-left: -12px;
margin-right: -12px;
border: none;
border-top: 1px solid #DDDDDD;
}
.confirmation-resend {
p {
margin-bottom: $default-padding;
}
.form {
display: flex;
flex-wrap: wrap;
input,
button {
margin-bottom: $default-spacer;
}
input[type=email] {
width: auto;
flex-grow: 1;
margin-right: $default-spacer;
}
}
label {
display: none;
}
}
}

View file

@ -0,0 +1,30 @@
@import "constants";
@import "colors";
#link-sent {
padding-top: 2 * $default-padding;
padding-bottom: 2 * $default-padding;
text-align: center;
max-width: 600px;
b {
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;
}
}

View file

@ -22,7 +22,7 @@ footer {
.footer-columns { .footer-columns {
@extend %horizontal-list; @extend %horizontal-list;
justify-content: flex-start; justify-content: flex-start;
margin: 0 -20px; margin: 0 -15px;
} }
.footer-column { .footer-column {

View file

@ -51,7 +51,7 @@
.brouillon, .brouillon,
.en-construction, .en-construction,
.en-instruction { .en-instruction {
max-width: 600px; max-width: 650px;
margin: auto; margin: auto;
} }

View file

@ -1,12 +1,17 @@
require 'zxcvbn' require 'zxcvbn'
class Administrateurs::ActivateController < ApplicationController class Administrateurs::ActivateController < ApplicationController
include TrustedDeviceConcern
layout "new_application" layout "new_application"
def new def new
@administrateur = Administrateur.find_inactive_by_token(params[:token]) @administrateur = Administrateur.find_inactive_by_token(params[:token])
if !@administrateur if @administrateur
# the administrateur activates its account from an email
trust_device
else
flash.alert = "Le lien de validation d'administrateur a expiré, #{helpers.contact_link('contactez-nous', tags: 'lien expiré')} pour obtenir un nouveau lien." flash.alert = "Le lien de validation d'administrateur a expiré, #{helpers.contact_link('contactez-nous', tags: 'lien expiré')} pour obtenir un nouveau lien."
redirect_to root_path redirect_to root_path
end end

View file

@ -1,10 +1,15 @@
class Gestionnaires::ActivateController < ApplicationController class Gestionnaires::ActivateController < ApplicationController
include TrustedDeviceConcern
layout "new_application" layout "new_application"
def new def new
@gestionnaire = Gestionnaire.with_reset_password_token(params[:token]) @gestionnaire = Gestionnaire.with_reset_password_token(params[:token])
if !@gestionnaire if @gestionnaire
# the gestionnaire activates its account from an email
trust_device
else
flash.alert = "Le lien de validation du compte instructeur a expiré, #{helpers.contact_link('contactez-nous', tags: 'lien expiré')} pour obtenir un nouveau lien." flash.alert = "Le lien de validation du compte instructeur a expiré, #{helpers.contact_link('contactez-nous', tags: 'lien expiré')} pour obtenir un nouveau lien."
redirect_to root_path redirect_to root_path
end end

View file

@ -6,5 +6,19 @@ module Manager
flash[:notice] = "Instructeur réinvité." flash[:notice] = "Instructeur réinvité."
redirect_to manager_gestionnaire_path(gestionnaire) redirect_to manager_gestionnaire_path(gestionnaire)
end end
def enable_feature
gestionnaire = Gestionnaire.find(params[:id])
params[:features].each do |key, enable|
if enable
gestionnaire.enable_feature(key.to_sym)
else
gestionnaire.disable_feature(key.to_sym)
end
end
head :ok
end
end end
end end

View file

@ -4,9 +4,10 @@ class Users::ConfirmationsController < Devise::ConfirmationsController
layout "new_application" layout "new_application"
# GET /resource/confirmation/new # GET /resource/confirmation/new
# def new def new
# super # Allow displaying the user email in the message
# end self.resource = resource_class.new(email: user_email_param)
end
# POST /resource/confirmation # POST /resource/confirmation
# def create # def create
@ -20,6 +21,10 @@ class Users::ConfirmationsController < Devise::ConfirmationsController
# protected # protected
def user_email_param
params.permit(user: :email).dig(:user, :email)
end
# The path used after resending confirmation instructions. # The path used after resending confirmation instructions.
# def after_resending_confirmation_instructions_path_for(resource_name) # def after_resending_confirmation_instructions_path_for(resource_name)
# super(resource_name) # super(resource_name)

View file

@ -22,20 +22,22 @@ class Users::RegistrationsController < Devise::RegistrationsController
# POST /resource # POST /resource
def create def create
user = User.find_by(email: params[:user][:email]) # Handle existing user trying to sign up again
if user.present? existing_user = User.find_by(email: params[:user][:email])
if user.confirmed? if existing_user.present?
UserMailer.new_account_warning(user).deliver_later if existing_user.confirmed?
else UserMailer.new_account_warning(existing_user).deliver_later
user.resend_confirmation_instructions
end
flash.notice = t('devise.registrations.signed_up_but_unconfirmed') flash.notice = t('devise.registrations.signed_up_but_unconfirmed')
redirect_to root_path return redirect_to root_path
else else
super existing_user.resend_confirmation_instructions
return redirect_to after_inactive_sign_up_path_for(existing_user)
end end
end end
super
end
# GET /resource/edit # GET /resource/edit
# def edit # def edit
# super # super
@ -78,7 +80,8 @@ class Users::RegistrationsController < Devise::RegistrationsController
# end # end
# The path used after sign up for inactive accounts. # The path used after sign up for inactive accounts.
# def after_inactive_sign_up_path_for(resource) def after_inactive_sign_up_path_for(resource)
# super(resource) flash.discard(:notice) # Remove devise's default message (as we have a custom page to explain it)
# end new_confirmation_path(resource, :user => { email: resource.email })
end
end end

View file

@ -1,4 +1,7 @@
class Users::SessionsController < Sessions::SessionsController class Users::SessionsController < Sessions::SessionsController
include TrustedDeviceConcern
include ActionView::Helpers::DateHelper
layout "new_application" layout "new_application"
# GET /resource/sign_in # GET /resource/sign_in
@ -23,13 +26,22 @@ class Users::SessionsController < Sessions::SessionsController
current_user.update(loged_in_with_france_connect: '') current_user.update(loged_in_with_france_connect: '')
end end
if user_signed_in? if gestionnaire_signed_in?
if trusted_device? || !current_gestionnaire.feature_enabled?(:enable_email_login_token)
set_flash_message :notice, :signed_in
redirect_to gestionnaire_procedures_path
else
gestionnaire = current_gestionnaire
login_token = gestionnaire.login_token!
GestionnaireMailer.send_login_token(gestionnaire, login_token).deliver_later
[:user, :gestionnaire, :administrateur].each { |role| sign_out(role) }
redirect_to link_sent_path(email: gestionnaire.email)
end
elsif user_signed_in?
set_flash_message :notice, :signed_in
redirect_to after_sign_in_path_for(:user) redirect_to after_sign_in_path_for(:user)
elsif gestionnaire_signed_in?
location = stored_location_for(:gestionnaire) || gestionnaire_procedures_path
redirect_to location
elsif administrateur_signed_in?
redirect_to admin_path
else else
flash.alert = 'Mauvais couple login / mot de passe' flash.alert = 'Mauvais couple login / mot de passe'
new new
@ -37,6 +49,10 @@ class Users::SessionsController < Sessions::SessionsController
end end
end end
def link_sent
@email = params[:email]
end
# DELETE /resource/sign_out # DELETE /resource/sign_out
def destroy def destroy
if gestionnaire_signed_in? if gestionnaire_signed_in?
@ -68,6 +84,27 @@ class Users::SessionsController < Sessions::SessionsController
redirect_to new_user_session_path redirect_to new_user_session_path
end end
def sign_in_by_link
gestionnaire = Gestionnaire.find(params[:id])
if gestionnaire&.login_token_valid?(params[:jeton])
trust_device
flash.notice = "Merci davoir confirmé votre connexion. Votre navigateur est maintenant authentifié pour #{TRUSTED_DEVICE_PERIOD.to_i / ActiveSupport::Duration::SECONDS_PER_DAY} jours."
user = User.find_by(email: gestionnaire.email)
administrateur = Administrateur.find_by(email: gestionnaire.email)
[user, gestionnaire, administrateur].compact.each { |resource| sign_in(resource) }
if administrateur.present?
redirect_to admin_procedures_path
else
redirect_to gestionnaire_procedures_path
end
else
flash[:alert] = 'Votre lien est invalide ou expiré, veuillez-vous reconnecter.'
redirect_to new_user_session_path
end
end
private private
def error_procedure def error_procedure
@ -92,7 +129,6 @@ class Users::SessionsController < Sessions::SessionsController
resource.remember_me = remember_me resource.remember_me = remember_me
sign_in resource sign_in resource
resource.force_sync_credentials resource.force_sync_credentials
set_flash_message :notice, :signed_in
end end
end end
end end

View file

@ -14,7 +14,8 @@ class GestionnaireDashboard < Administrate::BaseDashboard
updated_at: Field::DateTime, updated_at: Field::DateTime,
current_sign_in_at: Field::DateTime, current_sign_in_at: Field::DateTime,
dossiers: Field::HasMany, dossiers: Field::HasMany,
procedures: Field::HasMany procedures: Field::HasMany,
features: FeaturesField
}.freeze }.freeze
# COLLECTION_ATTRIBUTES # COLLECTION_ATTRIBUTES
@ -35,7 +36,8 @@ class GestionnaireDashboard < Administrate::BaseDashboard
:id, :id,
:email, :email,
:current_sign_in_at, :current_sign_in_at,
:created_at :created_at,
:features
].freeze ].freeze
# FORM_ATTRIBUTES # FORM_ATTRIBUTES

View file

@ -34,4 +34,12 @@ class GestionnaireMailer < ApplicationMailer
mail(to: recipient.email, subject: subject) mail(to: recipient.email, subject: subject)
end end
def send_login_token(gestionnaire, login_token)
@gestionnaire_id = gestionnaire.id
@login_token = login_token
subject = "Connexion sécurisée à demarches-simplifiees.fr"
mail(to: gestionnaire.email, subject: subject)
end
end end

View file

@ -19,11 +19,17 @@ class Administration < ApplicationRecord
if administrateur.save if administrateur.save
AdministrationMailer.new_admin_email(administrateur, self).deliver_later AdministrationMailer.new_admin_email(administrateur, self).deliver_later
administrateur.invite!(id) administrateur.invite!(id)
User.create({ User.create({
email: email, email: email,
password: password, password: password,
confirmed_at: Time.zone.now confirmed_at: Time.zone.now
}) })
Gestionnaire.create({
email: email,
password: password
})
end end
administrateur administrateur

View file

@ -0,0 +1,25 @@
module TrustedDeviceConcern
extend ActiveSupport::Concern
TRUSTED_DEVICE_COOKIE_NAME = :trusted_device
TRUSTED_DEVICE_PERIOD = 1.month
def trust_device
cookies.encrypted[TRUSTED_DEVICE_COOKIE_NAME] = {
value: JSON.generate({ created_at: Time.zone.now }),
expires: TRUSTED_DEVICE_PERIOD,
httponly: true
}
end
def trusted_device?
trusted_device_cookie.present? &&
Time.zone.now - TRUSTED_DEVICE_PERIOD < JSON.parse(trusted_device_cookie)['created_at']
end
private
def trusted_device_cookie
cookies.encrypted[TRUSTED_DEVICE_COOKIE_NAME]
end
end

View file

@ -1,6 +1,7 @@
class Gestionnaire < ApplicationRecord class Gestionnaire < ApplicationRecord
include CredentialsSyncableConcern include CredentialsSyncableConcern
include EmailSanitizableConcern include EmailSanitizableConcern
include ActiveRecord::SecureToken
devise :database_authenticatable, :registerable, :async, devise :database_authenticatable, :registerable, :async,
:recoverable, :rememberable, :trackable, :validatable :recoverable, :rememberable, :trackable, :validatable
@ -144,6 +145,20 @@ class Gestionnaire < ApplicationRecord
Dossier.where(id: dossiers_id_with_notifications(dossiers)).group(:procedure_id).count Dossier.where(id: dossiers_id_with_notifications(dossiers)).group(:procedure_id).count
end end
def login_token!
login_token = Gestionnaire.generate_unique_secure_token
encrypted_login_token = BCrypt::Password.create(login_token)
update(encrypted_login_token: encrypted_login_token, login_token_created_at: Time.zone.now)
login_token
end
def login_token_valid?(login_token)
BCrypt::Password.new(encrypted_login_token) == login_token
30.minutes.ago < login_token_created_at
rescue BCrypt::Errors::InvalidHash
false
end
def dossiers_id_with_notifications(dossiers) def dossiers_id_with_notifications(dossiers)
dossiers = dossiers.followed_by(self) dossiers = dossiers.followed_by(self)
@ -190,6 +205,23 @@ class Gestionnaire < ApplicationRecord
GestionnaireMailer.invite_gestionnaire(self, reset_password_token).deliver_later GestionnaireMailer.invite_gestionnaire(self, reset_password_token).deliver_later
end end
def feature_enabled?(feature)
Flipflop.feature_set.feature(feature)
features[feature.to_s]
end
def disable_feature(feature)
Flipflop.feature_set.feature(feature)
features.delete(feature.to_s)
save
end
def enable_feature(feature)
Flipflop.feature_set.feature(feature)
features[feature.to_s] = true
save
end
private private
def annotations_hash(demande, annotations_privees, avis, messagerie) def annotations_hash(demande, annotations_privees, avis, messagerie)

View file

@ -1,3 +1,5 @@
require Rails.root.join('lib', 'percentile')
class Procedure < ApplicationRecord class Procedure < ApplicationRecord
MAX_DUREE_CONSERVATION = 36 MAX_DUREE_CONSERVATION = 36
@ -304,16 +306,16 @@ class Procedure < ApplicationRecord
end end
end end
def mean_traitement_time def usual_traitement_time
mean_time(:en_construction_at, :processed_at) percentile_time(:en_construction_at, :processed_at, 90)
end end
def mean_verification_time def usual_verification_time
mean_time(:en_construction_at, :en_instruction_at) percentile_time(:en_construction_at, :en_instruction_at, 90)
end end
def mean_instruction_time def usual_instruction_time
mean_time(:en_instruction_at, :processed_at) percentile_time(:en_instruction_at, :processed_at, 90)
end end
PATH_AVAILABLE = :available PATH_AVAILABLE = :available
@ -421,14 +423,14 @@ class Procedure < ApplicationRecord
true true
end end
def mean_time(start_attribute, end_attribute) def percentile_time(start_attribute, end_attribute, p)
times = dossiers times = dossiers
.state_termine .state_termine
.pluck(start_attribute, end_attribute) .pluck(start_attribute, end_attribute)
.map { |(start_date, end_date)| end_date - start_date } .map { |(start_date, end_date)| end_date - start_date }
if times.present? if times.present?
times.sum.fdiv(times.size).ceil times.percentile(p).ceil
end end
end end
end end

View file

@ -1,7 +1,14 @@
:ruby
url = if field.resource.class.name == 'Gestionnaire'
enable_feature_manager_gestionnaire_path(field.resource.id)
else
enable_feature_manager_administrateur_path(field.resource.id)
end
%table#features %table#features
- Flipflop.feature_set.features.each do |feature| - Flipflop.feature_set.features.each do |feature|
- if !feature.group || feature.group.key != :production - if !feature.group || feature.group.key != :production
%tr %tr
%td= feature.title %td= feature.title
%td %td
= check_box_tag "enable-feature", "enable", field.data[feature.name], data: { url: enable_feature_manager_administrateur_path(field.resource.id), key: feature.key } = check_box_tag "enable-feature", "enable", field.data[feature.name], data: { url: url, key: feature.key }

View file

@ -0,0 +1,12 @@
%p
Bonjour,
%p
Veuillez cliquer sur le lien suivant pour vous connecter sur le site demarches-simplifiees.fr : 
= link_to(sign_in_by_link_url(@gestionnaire_id, jeton: @login_token), sign_in_by_link_url(@gestionnaire_id, jeton: @login_token))
%p
Bonne journée,
%p
L'équipe demarches-simplifiees.fr

View file

@ -31,10 +31,10 @@
%strong votre dossier passera directement en instruction %strong votre dossier passera directement en instruction
/ FIXME: remove the custom procedure switch at some point / FIXME: remove the custom procedure switch at some point
- if dossier.procedure.mean_verification_time && show_time_means - if dossier.procedure.usual_verification_time && show_time_means
- cache(dossier.procedure, expires_in: 1.week) do - cache(dossier.procedure, expires_in: 1.week) do
%p %p
Le temps moyen de vérification pour cette démarche est de #{distance_of_time_in_words(dossier.procedure.mean_verification_time)}. Habituellement, les dossiers de cette démarche sont vérifiés dans un délai de #{distance_of_time_in_words(dossier.procedure.usual_verification_time)}.
- elsif dossier.en_instruction? - elsif dossier.en_instruction?
.en-instruction .en-instruction
@ -46,10 +46,10 @@
avec le résultat. avec le résultat.
/ FIXME: remove the custom procedure switch at some point / FIXME: remove the custom procedure switch at some point
- if dossier.procedure.mean_instruction_time && show_time_means - if dossier.procedure.usual_instruction_time && show_time_means
- cache(dossier.procedure, expires_in: 1.week) do - cache(dossier.procedure, expires_in: 1.week) do
%p %p
Le temps moyen dinstruction pour cette démarche est de #{distance_of_time_in_words(dossier.procedure.mean_instruction_time)}. Habituellement, les dossiers de cette démarche sont traités dans un délai de #{distance_of_time_in_words(dossier.procedure.usual_instruction_time)}.
- elsif dossier.accepte? - elsif dossier.accepte?
.accepte .accepte

View file

@ -1,17 +1,31 @@
- content_for(:title, 'Renvoyer les instructions de confirmation de compte') - content_for(:title, 'Confirmer votre adresse email')
- content_for :footer do - content_for :footer do
= render partial: 'root/footer' = render partial: 'root/footer'
.container.devise-container .container.devise-container.devise-confirmations
.one-column-centered .one-column-centered
= devise_error_messages! = devise_error_messages!
%img.confirmation-icon{ src: image_url("user/confirmation-email.svg"), alt: "" }
%p.confirmation-preamble
= succeed '.' do
Avant deffectuer votre démarche, nous avons besoin de vérifier votre adresse
- if resource.email.present?
%strong= resource.email
%p.confirmation-instructions
Ouvrez votre boîte email, et
%strong cliquez sur le lien dactivation
dans le message que vous avez reçu.
%hr.confirmation-separator
.confirmation-resend
%p Si vous navez pas reçu notre message, nous pouvons vous le renvoyer.
= form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { class: 'form' }) do |f| = form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { class: 'form' }) do |f|
%h1 Renvoyer les instructions de confirmation de compte
= f.label :email, 'Email' = f.label :email, 'Email'
= f.email_field :email, autofocus: true = f.email_field :email, placeholder: 'Email', class: 'small', autofocus: true
= f.submit 'Renvoyer un email de confirmation', class: 'button'
= f.submit 'Renvoyer les instructions de confirmation', class: 'button primary'

View file

@ -0,0 +1,14 @@
- content_for(:title, 'Lien de connexion par email')
- content_for :footer do
= render partial: 'root/footer'
#link-sent.container
= image_tag('user/confirmation-email.svg')
%h1 Encore une petite étape :)
%p.mail
Ouvrez votre boite email <b>#{@email}</b> puis cliquez sur le lien d'activation du message <b>Connexion sécurisée à demarches-simplifiees.fr</b>.
%p.help
En cas de difficultés, nous restons joignables sur #{link_to 'contact@demarches-simplifiees.fr', 'mailto:contact@demarches-simplifiees.fr'}.

View file

@ -20,6 +20,7 @@ Flipflop.configure do
feature :web_hook feature :web_hook
feature :publish_draft feature :publish_draft
feature :support_form feature :support_form
feature :enable_email_login_token
group :production do group :production do
feature :remote_storage, feature :remote_storage,

View file

@ -26,6 +26,7 @@ Rails.application.routes.draw do
resources :gestionnaires, only: [:index, :show] do resources :gestionnaires, only: [:index, :show] do
post 'reinvite', on: :member post 'reinvite', on: :member
put 'enable_feature', on: :member
end end
resources :dossiers, only: [:show] resources :dossiers, only: [:show]
@ -88,6 +89,8 @@ Rails.application.routes.draw do
devise_scope :user do devise_scope :user do
get '/users/sign_in/demo' => redirect("/users/sign_in") get '/users/sign_in/demo' => redirect("/users/sign_in")
get '/users/no_procedure' => 'users/sessions#no_procedure' 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'
end end
devise_scope :gestionnaire do devise_scope :gestionnaire do

View file

@ -0,0 +1,6 @@
class AddEncryptedLoginTokenColumnToGestionnaire < ActiveRecord::Migration[5.2]
def change
add_column :gestionnaires, :encrypted_login_token, :text
add_column :gestionnaires, :login_token_created_at, :datetime
end
end

View file

@ -0,0 +1,5 @@
class AddFeaturesColumnToGestionnaires < ActiveRecord::Migration[5.2]
def change
add_column :gestionnaires, :features, :jsonb, null: false, default: {}
end
end

View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2018_10_30_141238) do ActiveRecord::Schema.define(version: 2018_11_08_151929) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -365,6 +365,9 @@ ActiveRecord::Schema.define(version: 2018_10_30_141238) do
t.string "last_sign_in_ip" t.string "last_sign_in_ip"
t.datetime "created_at" t.datetime "created_at"
t.datetime "updated_at" t.datetime "updated_at"
t.text "encrypted_login_token"
t.datetime "login_token_created_at"
t.jsonb "features", default: {}, null: false
t.index ["email"], name: "index_gestionnaires_on_email", unique: true t.index ["email"], name: "index_gestionnaires_on_email", unique: true
t.index ["reset_password_token"], name: "index_gestionnaires_on_reset_password_token", unique: true t.index ["reset_password_token"], name: "index_gestionnaires_on_reset_password_token", unique: true
end end

31
lib/percentile.rb Normal file
View file

@ -0,0 +1,31 @@
# Adapted from https://github.com/thirtysixthspan/descriptive_statistics
# Copyright (c) 2010-2014 Derrick Parkhurst (derrick.parkhurst@gmail.com)
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
class Array
def percentile(p)
values = self.sort
if values.empty?
return []
elsif values.size == 1
return values.first
elsif p == 100
return values.last
end
rank = p / 100.0 * (values.size - 1)
lower, upper = values[rank.floor, 2]
lower + (upper - lower) * (rank - rank.floor)
end
end

View file

@ -0,0 +1,15 @@
namespace :'2018_10_30_admin_has_gestionnaire' do
task run: :environment do
admin_without_gestionnaire_ids = Administrateur
.find_by_sql('SELECT administrateurs.id FROM administrateurs LEFT OUTER JOIN gestionnaires ON gestionnaires.email = administrateurs.email WHERE gestionnaires.email IS NULL')
.pluck(:id)
admin_without_gestionnaire_ids.each do |admin_id|
admin = Administrateur.find(admin_id)
g = Gestionnaire.new
g.email = admin.email
g.encrypted_password = admin.encrypted_password
g.save(validate: false)
end
end
end

View file

@ -0,0 +1,8 @@
namespace :'activate_trusted_device_for_a-f' do
task run: :environment do
letters_a_to_f = ('a'..'f').to_a
Gestionnaire
.where("substr(email, 1, 1) IN (?)", letters_a_to_f)
.update_all(features: { "enable_email_login_token" => true })
end
end

View file

@ -0,0 +1,20 @@
describe Administrateurs::ActivateController, type: :controller do
describe '#new' do
let(:admin) { create(:administrateur) }
let(:token) { admin.send(:set_reset_password_token) }
before { allow(controller).to receive(:trust_device) }
context 'when the token is ok' do
before { get :new, params: { token: token } }
it { expect(controller).to have_received(:trust_device) }
end
context 'when the token is bad' do
before { get :new, params: { token: 'bad' } }
it { expect(controller).not_to have_received(:trust_device) }
end
end
end

View file

@ -0,0 +1,20 @@
describe Gestionnaires::ActivateController, type: :controller do
describe '#new' do
let(:gestionnaire) { create(:gestionnaire) }
let(:token) { gestionnaire.send(:set_reset_password_token) }
before { allow(controller).to receive(:trust_device) }
context 'when the token is ok' do
before { get :new, params: { token: token } }
it { expect(controller).to have_received(:trust_device) }
end
context 'when the token is bad' do
before { get :new, params: { token: 'bad' } }
it { expect(controller).not_to have_received(:trust_device) }
end
end
end

View file

@ -74,8 +74,7 @@ describe Users::RegistrationsController, type: :controller do
subject subject
end end
it { expect(response).to redirect_to(root_path) } it { expect(response).to redirect_to(new_user_confirmation_path(user: { email: user[:email] })) }
it { expect(flash.notice).to eq(I18n.t('devise.registrations.signed_up_but_unconfirmed')) }
it { expect(UserMailer).not_to have_received(:new_account_warning) } it { expect(UserMailer).not_to have_received(:new_account_warning) }
end end
end end

View file

@ -27,7 +27,7 @@ describe Users::SessionsController, type: :controller do
let(:password) { 'un super mot de passe' } let(:password) { 'un super mot de passe' }
let(:user) { create(:user, email: email, password: password) } let(:user) { create(:user, email: email, password: password) }
let(:gestionnaire) { create(:gestionnaire, email: email, password: password) } let(:gestionnaire) { create(:gestionnaire, :with_trusted_device, email: email, password: password) }
let(:administrateur) { create(:administrateur, email: email, password: password) } let(:administrateur) { create(:administrateur, email: email, password: password) }
it 'signs user in' do it 'signs user in' do
@ -41,18 +41,39 @@ describe Users::SessionsController, type: :controller do
it 'signs gestionnaire in' do it 'signs gestionnaire in' do
post :create, params: { user: { email: gestionnaire.email, password: gestionnaire.password } } post :create, params: { user: { email: gestionnaire.email, password: gestionnaire.password } }
expect(@response.redirect?).to be(true)
expect(subject).to redirect_to link_sent_path(email: gestionnaire.email)
expect(subject.current_user).to be(nil)
expect(subject.current_gestionnaire).to be(nil)
expect(subject.current_administrateur).to be(nil)
end
context 'when the device is trusted' do
before do
allow(controller).to receive(:trusted_device?).and_return(true)
post :create, params: { user: { email: gestionnaire.email, password: gestionnaire.password } }
end
it 'directly log the gestionnaire' do
expect(subject).to redirect_to gestionnaire_procedures_path
expect(subject.current_user).to be(nil) expect(subject.current_user).to be(nil)
expect(subject.current_gestionnaire).to eq(gestionnaire) expect(subject.current_gestionnaire).to eq(gestionnaire)
expect(subject.current_administrateur).to be(nil) expect(subject.current_administrateur).to be(nil)
end end
end
context 'signs administrateur in' do
# an admin has always an gestionnaire role
before { gestionnaire }
it 'signs administrateur in' do it 'signs administrateur in' do
post :create, params: { user: { email: administrateur.email, password: administrateur.password } } post :create, params: { user: { email: administrateur.email, password: administrateur.password } }
expect(@response.redirect?).to be(true)
expect(subject).to redirect_to link_sent_path(email: gestionnaire.email)
expect(subject.current_user).to be(nil) expect(subject.current_user).to be(nil)
expect(subject.current_gestionnaire).to be(nil) expect(subject.current_gestionnaire).to be(nil)
expect(subject.current_administrateur).to eq(administrateur) expect(subject.current_administrateur).to eq(nil)
end
end end
context { context {
@ -63,10 +84,16 @@ describe Users::SessionsController, type: :controller do
it 'signs user + gestionnaire + administrateur in' do it 'signs user + gestionnaire + administrateur in' do
post :create, params: { user: { email: administrateur.email, password: administrateur.password } } post :create, params: { user: { email: administrateur.email, password: administrateur.password } }
expect(@response.redirect?).to be(true)
expect(subject.current_user).to eq(user) expect(subject).to redirect_to link_sent_path(email: gestionnaire.email)
expect(subject.current_gestionnaire).to eq(gestionnaire)
expect(subject.current_administrateur).to eq(administrateur) # TODO: fix me
# Strange behaviour: sign_out(:user) does not work in spec
# but seems to work in live
# expect(controller.current_user).to be(nil)
expect(subject.current_gestionnaire).to be(nil)
expect(subject.current_administrateur).to be(nil)
expect(user.reload.loged_in_with_france_connect).to be(nil) expect(user.reload.loged_in_with_france_connect).to be(nil)
end end
} }
@ -219,4 +246,75 @@ describe Users::SessionsController, type: :controller do
end end
end end
end end
describe '#sign_in_by_link' do
context 'when the gestionnaire has non other account' do
let(:gestionnaire) { create(:gestionnaire) }
before do
allow(controller).to receive(:trust_device)
post :sign_in_by_link, params: { id: gestionnaire.id, login_token: login_token }
end
context 'when the token is valid' do
let(:login_token) { gestionnaire.login_token! }
it { is_expected.to redirect_to gestionnaire_procedures_path }
it { expect(controller.current_gestionnaire).to eq(gestionnaire) }
it { expect(controller).to have_received(:trust_device) }
end
context 'when the token is invalid' do
let(:login_token) { 'invalid_token' }
it { is_expected.to redirect_to new_user_session_path }
it { expect(controller.current_gestionnaire).to be_nil }
it { expect(controller).not_to have_received(:trust_device) }
end
end
context 'when the gestionnaire has an user and admin account' do
let(:email) { 'unique@plop.com' }
let(:password) { 'un super mot de passe' }
let!(:user) { create(:user, email: email, password: password) }
let!(:gestionnaire) { create(:gestionnaire, email: email, password: password) }
let!(:administrateur) { create(:administrateur, email: email, password: password) }
before do
post :sign_in_by_link, params: { id: gestionnaire.id, login_token: login_token }
end
context 'when the token is valid' do
let(:login_token) { gestionnaire.login_token! }
it { expect(controller.current_gestionnaire).to eq(gestionnaire) }
it { expect(controller.current_administrateur).to eq(administrateur) }
it { expect(controller.current_user).to eq(user) }
end
end
end
describe '#trust_device and #trusted_device?' do
subject { controller.trusted_device? }
context 'when the trusted cookie is not present' do
it { is_expected.to be false }
end
context 'when the cookie is outdated' do
before do
Timecop.freeze(Time.zone.now - TrustedDeviceConcern::TRUSTED_DEVICE_PERIOD - 1.minute)
controller.trust_device
Timecop.return
end
it { is_expected.to be false }
end
context 'when the cookie is ok' do
before { controller.trust_device }
it { is_expected.to be true }
end
end
end end

View file

@ -4,4 +4,10 @@ FactoryBot.define do
email { generate(:gestionnaire_email) } email { generate(:gestionnaire_email) }
password { 'password' } password { 'password' }
end end
trait :with_trusted_device do
after(:create) do |gestionnaire|
gestionnaire.update(features: { "enable_email_login_token" => true })
end
end
end end

View file

@ -1,20 +1,26 @@
require 'spec_helper' require 'spec_helper'
feature 'Administrator connection' do feature 'Administrator connection' do
let(:admin) { create(:administrateur) } include ActiveJob::TestHelper
let(:email) { 'admin1@admin.com' }
let(:password) { 'mon chien aime les bananes' }
let!(:admin) { create(:administrateur, email: email, password: password) }
let!(:gestionnaire) { create(:gestionnaire, :with_trusted_device, email: email, password: password) }
before do before do
visit new_administrateur_session_path visit new_administrateur_session_path
end end
scenario 'administrator is on sign in page' do scenario 'administrator is on sign in page' do
expect(page).to have_css('#new_user') expect(page).to have_css('#new_user')
end end
context "admin fills form and log in" do context "admin fills form and log in" do
before do before do
page.find_by_id('user_email').set admin.email sign_in_with(email, password, true)
page.find_by_id('user_password').set admin.password
page.click_on 'Se connecter'
end end
scenario 'a menu button is available' do scenario 'a menu button is available' do
expect(page).to have_css('#admin_menu') expect(page).to have_css('#admin_menu')
end end

View file

@ -4,7 +4,7 @@ feature 'The gestionnaire part' do
include ActiveJob::TestHelper include ActiveJob::TestHelper
let(:password) { 'secret_password' } let(:password) { 'secret_password' }
let!(:gestionnaire) { create(:gestionnaire, password: password) } let!(:gestionnaire) { create(:gestionnaire, :with_trusted_device, password: password) }
let!(:procedure) { create(:procedure, :published, gestionnaires: [gestionnaire]) } let!(:procedure) { create(:procedure, :published, gestionnaires: [gestionnaire]) }
let!(:dossier) { create(:dossier, state: Dossier.states.fetch(:en_construction), procedure: procedure) } let!(:dossier) { create(:dossier, state: Dossier.states.fetch(:en_construction), procedure: procedure) }
@ -116,7 +116,7 @@ feature 'The gestionnaire part' do
log_out log_out
log_in(gestionnaire.email, password) log_in(gestionnaire.email, password, check_email: false)
click_on procedure.libelle click_on procedure.libelle
click_on dossier.user.email click_on dossier.user.email
@ -173,14 +173,13 @@ feature 'The gestionnaire part' do
expect(page).to have_text("Dossier envoyé") expect(page).to have_text("Dossier envoyé")
end end
def log_in(email, password) def log_in(email, password, check_email: true)
visit '/' visit '/'
click_on 'Connexion' click_on 'Connexion'
expect(page).to have_current_path(new_user_session_path) expect(page).to have_current_path(new_user_session_path)
fill_in 'user_email', with: email sign_in_with(email, password, check_email)
fill_in 'user_password', with: password
click_on 'Se connecter'
expect(page).to have_current_path(gestionnaire_procedures_path) expect(page).to have_current_path(gestionnaire_procedures_path)
end end
@ -196,7 +195,7 @@ feature 'The gestionnaire part' do
end end
def test_mail(to, content) def test_mail(to, content)
mail = ActionMailer::Base.deliveries.first mail = ActionMailer::Base.deliveries.last
expect(mail.to).to match([to]) expect(mail.to).to match([to])
expect(mail.body.parts.map(&:to_s)).to all(include(content)) expect(mail.body.parts.map(&:to_s)).to all(include(content))
end end

View file

@ -23,7 +23,7 @@ describe 'Dossier details:' do
visit dossier_path(dossier) visit dossier_path(dossier)
end end
it { expect(page).to have_text("Le temps moyen de vérification pour cette démarche est de 10 jours.") } it { expect(page).to have_text("Habituellement, les dossiers de cette démarche sont vérifiés dans un délai de 10 jours.") }
end end
context "when the dossier is in instruction" do context "when the dossier is in instruction" do
@ -34,7 +34,7 @@ describe 'Dossier details:' do
visit dossier_path(dossier) visit dossier_path(dossier)
end end
it { expect(page).to have_text("Le temps moyen dinstruction pour cette démarche est de 2 mois.") } it { expect(page).to have_text("Habituellement, les dossiers de cette démarche sont traités dans un délai de 2 mois.") }
end end
end end

View file

@ -37,7 +37,7 @@ feature 'Invitations' do
# Create the account # Create the account
sign_up_with invite.email, user_password sign_up_with invite.email, user_password
expect(page).to have_content("lien d'activation") expect(page).to have_content('lien dactivation')
# Confirm the account # Confirm the account
# (The user should be redirected to the dossier they was invited on) # (The user should be redirected to the dossier they was invited on)

View file

@ -1,15 +1,18 @@
require 'spec_helper' require 'spec_helper'
feature 'Signin up:' do feature 'Signin up:' do
let(:user_email) { generate :user_email }
let(:user_password) { 'testpassword' }
scenario 'a new user can sign-up' do scenario 'a new user can sign-up' do
visit root_path visit root_path
click_on 'Connexion' click_on 'Connexion'
click_on 'Créer un compte' click_on 'Créer un compte'
sign_up_with 'testuser@exemple.fr' sign_up_with user_email, user_password
expect(page).to have_content "Nous vous avons envoyé un email contenant un lien d'activation" expect(page).to have_content "nous avons besoin de vérifier votre adresse #{user_email}"
click_confirmation_link_for 'testuser@exemple.fr' click_confirmation_link_for user_email
expect(page).to have_content 'Votre compte a été activé' expect(page).to have_content 'Votre compte a été activé'
expect(page).to have_current_path dossiers_path expect(page).to have_current_path dossiers_path
end end
@ -25,12 +28,37 @@ feature 'Signin up:' do
expect(page).to have_current_path new_user_session_path expect(page).to have_current_path new_user_session_path
click_on 'Créer un compte' click_on 'Créer un compte'
sign_up_with 'testuser@exemple.fr' sign_up_with user_email, user_password
expect(page).to have_content "Nous vous avons envoyé un email contenant un lien d'activation" expect(page).to have_content "nous avons besoin de vérifier votre adresse #{user_email}"
click_confirmation_link_for 'testuser@exemple.fr' click_confirmation_link_for user_email
expect(page).to have_content 'Votre compte a été activé' expect(page).to have_content 'Votre compte a été activé'
expect(page).to have_content procedure.libelle expect(page).to have_content procedure.libelle
end end
end end
context 'when a user is not confirmed yet' do
before do
visit root_path
click_on 'Connexion'
click_on 'Créer un compte'
sign_up_with user_email, user_password
end
# Ideally, when signing-in with an unconfirmed account,
# the user would be redirected to the "resend email confirmation" page.
#
# However the check for unconfirmed accounts is made by Warden every time a page is loaded 
# and much earlier than SessionsController#create.
#
# For now only test the default behavior (an error message is displayed).
scenario 'they get an error message' do
visit root_path
click_on 'Connexion'
sign_in_with user_email, user_password
expect(page).to have_content 'Vous devez confirmer votre adresse email pour continuer'
end
end
end end

View file

@ -7,4 +7,8 @@ class GestionnaireMailerPreview < ActionMailer::Preview
def send_dossier def send_dossier
GestionnaireMailer.send_dossier(Gestionnaire.first, Dossier.first, Gestionnaire.last) GestionnaireMailer.send_dossier(Gestionnaire.first, Dossier.first, Gestionnaire.last)
end end
def send_login_token
GestionnaireMailer.send_login_token(Gestionnaire.first, "token")
end
end end

View file

@ -20,6 +20,12 @@ describe Administration, type: :model do
expect(user).to be_present expect(user).to be_present
end end
it 'creates a corresponding gestionnaire account for the email' do
subject
gestionnaire = Gestionnaire.find_by(email: valid_email)
expect(gestionnaire).to be_present
end
context 'when there already is a user account with the same email' do context 'when there already is a user account with the same email' do
before { create(:user, email: valid_email) } before { create(:user, email: valid_email) }
it 'still creates an admin account' do it 'still creates an admin account' do

View file

@ -705,22 +705,34 @@ describe Procedure do
end end
end end
describe '#mean_instruction_time' do describe '#usual_instruction_time' do
let(:procedure) { create(:procedure) } let(:procedure) { create(:procedure) }
context 'when there is only one dossier' do
let(:dossier) { create(:dossier, procedure: procedure) }
context 'which is termine' do
before do before do
dossier.accepte! processed_delays.each do |delay|
processed_date = Time.zone.parse('12/12/2012') dossier = create :dossier, :accepte, procedure: procedure
instruction_date = processed_date - 1.day instruction_date = 1.month.ago
dossier.update(en_instruction_at: instruction_date, processed_at: processed_date) processed_date = instruction_date + delay
dossier.update!(en_instruction_at: instruction_date, processed_at: processed_date)
end
end end
it { expect(procedure.mean_instruction_time).to eq(1.day.to_i) } context 'when there are several processed dossiers' do
let(:processed_delays) { [1.day, 2.days, 2.days, 2.days, 2.days, 3.days, 3.days, 3.days, 3.days, 12.days] }
it 'returns a time representative of the dossier instruction delay' do
expect(procedure.usual_instruction_time).to be_between(3.days, 4.days)
end end
end end
context 'when there is only one processed dossier' do
let(:processed_delays) { [1.day] }
it { expect(procedure.usual_instruction_time).to eq(1.day) }
end
context 'where there is no processed dossier' do
let(:processed_delays) { [] }
it { expect(procedure.usual_instruction_time).to be_nil }
end
end end
end end

View file

@ -17,12 +17,23 @@ module FeatureHelpers
dossier dossier
end end
def sign_in_with(email, password) def sign_in_with(email, password, sign_in_by_link = false)
fill_in :user_email, with: email fill_in :user_email, with: email
fill_in :user_password, with: password fill_in :user_password, with: password
perform_enqueued_jobs do
click_on 'Se connecter' click_on 'Se connecter'
end end
if sign_in_by_link
mail = ActionMailer::Base.deliveries.last
message = mail.body.parts.join(&:to_s)
login_token = message[/connexion-par-jeton\/(.*)/, 1]
visit sign_in_by_link_path(login_token)
end
end
def sign_up_with(email, password = 'testpassword') def sign_up_with(email, password = 'testpassword')
fill_in :user_email, with: email fill_in :user_email, with: email
fill_in :user_password, with: password fill_in :user_password, with: password