Merge pull request #10570 from demarches-simplifiees/feat/10425

ETQ Usager se connectant par FC, je dois confirmer mon mail
This commit is contained in:
LeSim 2024-09-05 08:07:23 +00:00 committed by GitHub
commit 9fd53b182a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 660 additions and 332 deletions

View file

@ -2,8 +2,9 @@
class FranceConnect::ParticulierController < ApplicationController class FranceConnect::ParticulierController < ApplicationController
before_action :redirect_to_login_if_fc_aborted, only: [:callback] before_action :redirect_to_login_if_fc_aborted, only: [:callback]
before_action :securely_retrieve_fci, only: [:merge, :merge_with_existing_account, :merge_with_new_account, :resend_and_renew_merge_confirmation] before_action :securely_retrieve_fci, only: [:merge_using_fc_email, :merge_using_password, :send_email_merge_request]
before_action :securely_retrieve_fci_from_email_merge_token, only: [:mail_merge_with_existing_account] before_action :securely_retrieve_fci_from_email_merge_token, only: [:merge_using_email_link]
before_action :set_user_by_confirmation_token, only: [:confirm_email]
def login def login
if FranceConnectService.enabled? if FranceConnectService.enabled?
@ -14,112 +15,136 @@ class FranceConnect::ParticulierController < ApplicationController
end end
def callback def callback
fci = FranceConnectService.find_or_retrieve_france_connect_information(params[:code]) @fci = FranceConnectService.find_or_retrieve_france_connect_information(params[:code])
if fci.user.nil? if @fci.user.nil?
preexisting_unlinked_user = User.find_by(email: sanitize(fci.email_france_connect)) preexisting_unlinked_user = User.find_by(email: sanitize(@fci.email_france_connect))
if preexisting_unlinked_user.nil? if preexisting_unlinked_user.nil?
fci.associate_user!(fci.email_france_connect) @fci.create_merge_token!
connect_france_connect_particulier(fci.user) render :choose_email
elsif !preexisting_unlinked_user.can_france_connect? elsif preexisting_unlinked_user.can_france_connect?
fci.destroy @fci.create_merge_token!
redirect_to new_user_session_path, alert: t('errors.messages.france_connect.forbidden_html', reset_link: new_user_password_path) render :merge
else else
merge_token = fci.create_merge_token! destroy_fci_and_redirect_to_login(@fci)
redirect_to france_connect_particulier_merge_path(merge_token)
end end
else else
user = fci.user if @fci.user.can_france_connect?
@fci.update(updated_at: Time.zone.now)
if user.can_france_connect? connect_france_connect_particulier(@fci.user)
fci.update(updated_at: Time.zone.now) else
connect_france_connect_particulier(user) destroy_fci_and_redirect_to_login(@fci)
else # same behaviour as redirect nicely with message when instructeur/administrateur
fci.destroy
redirect_to new_user_session_path, alert: t('errors.messages.france_connect.forbidden_html', reset_link: new_user_password_path)
end end
end end
rescue Rack::OAuth2::Client::Error => e rescue Rack::OAuth2::Client::Error => e
Rails.logger.error e.message Rails.logger.error e.message
redirect_france_connect_error_connection redirect_to(new_user_session_path, alert: t('errors.messages.france_connect.connexion'))
end end
def merge def send_email_merge_request
end @fci.update(requested_email: sanitized_email_params)
def merge_with_existing_account
user = User.find_by(email: sanitized_email_params)
if user.present? && user.valid_for_authentication? { user.valid_password?(password_params) }
if !user.can_france_connect?
flash.alert = t('errors.messages.france_connect.forbidden_html', reset_link: new_user_password_path)
redirect_to root_path
else
@fci.update(user: user)
@fci.delete_merge_token!
@fci.delete_email_merge_token!
flash.notice = t('france_connect.particulier.flash.connection_done', application_name: Current.application_name)
connect_france_connect_particulier(user)
end
else
flash.alert = t('france_connect.particulier.flash.invalid_password')
end
end
def mail_merge_with_existing_account
user = User.find_by(email: sanitize(@fci.email_france_connect.downcase))
if user.can_france_connect?
@fci.update(user: user)
@fci.delete_merge_token!
flash.notice = t('france_connect.particulier.flash.connection_done', application_name: Current.application_name)
connect_france_connect_particulier(user)
else # same behaviour as redirect nicely with message when instructeur/administrateur
@fci.destroy
redirect_to new_user_session_path, alert: t('errors.messages.france_connect.forbidden_html', reset_link: new_user_password_path)
end
end
def merge_with_new_account
user = User.find_by(email: sanitized_email_params)
if user.nil?
@fci.associate_user!(sanitized_email_params)
@fci.delete_merge_token!
flash.notice = t('france_connect.particulier.flash.connection_done', application_name: Current.application_name)
connect_france_connect_particulier(@fci.user)
else
@email = sanitized_email_params
@merge_token = merge_token_params
end
end
def resend_and_renew_merge_confirmation
@fci.create_email_merge_token! @fci.create_email_merge_token!
UserMailer.france_connect_merge_confirmation( UserMailer.france_connect_merge_confirmation(
@fci.email_france_connect, sanitized_email_params,
@fci.email_merge_token, @fci.email_merge_token,
@fci.email_merge_token_created_at @fci.email_merge_token_created_at
) )
.deliver_later .deliver_later
merge_token = @fci.create_merge_token! redirect_to root_path, notice: t('france_connect.particulier.flash.confirmation_mail_sent')
redirect_to france_connect_particulier_merge_path(merge_token), end
notice: t('france_connect.particulier.flash.confirmation_mail_sent')
def merge_using_fc_email
@fci.safely_associate_user!(@fci.email_france_connect)
sign_in(@fci.user)
@fci.send_custom_confirmation_instructions
render :confirmation_sent, locals: { email: @fci.email_france_connect, destination_path: destination_path(@fci.user) }
end
def merge_using_password
user = User.find_by(email: sanitize(@fci.email_france_connect))
if user.present? && !user.can_france_connect?
return destroy_fci_and_redirect_to_login(@fci)
end
if user.present? && user.valid_for_authentication? { user.valid_password?(params[:password]) }
@fci.safely_update_user(user:)
flash.notice = t('france_connect.particulier.flash.connection_done', application_name: Current.application_name)
connect_france_connect_particulier(user)
else
flash.alert = t('france_connect.particulier.flash.invalid_password')
end
end
def merge_using_email_link
user = User.find_by(email: @fci.requested_email)
if user.present? && !user.can_france_connect?
return destroy_fci_and_redirect_to_login(@fci)
end
if user.nil?
@fci.safely_associate_user!(@fci.requested_email)
else
@fci.safely_update_user(user:)
end
@fci.user.update(email_verified_at: Time.zone.now)
flash.notice = t('france_connect.particulier.flash.connection_done', application_name: Current.application_name)
connect_france_connect_particulier(@fci.user)
end
# TODO mutualiser avec le controller Users::ActivateController
# pour toute la partie de confirmation de compte
def confirm_email
if @user.confirmation_sent_at && 2.days.ago < @user.confirmation_sent_at
@user.update(email_verified_at: Time.zone.now, confirmation_token: nil)
@user.after_confirmation
redirect_to destination_path(@user), notice: I18n.t('france_connect.particulier.flash.email_confirmed')
return
end
fci = FranceConnectInformation.find_by(user: @user)
if fci
fci.send_custom_confirmation_instructions
redirect_to root_path, notice: I18n.t('france_connect.particulier.flash.confirmation_mail_resent')
else
redirect_to root_path, alert: I18n.t('france_connect.particulier.flash.confirmation_mail_resent_error')
end
end end
private private
def set_user_by_confirmation_token
@user = User.find_by(confirmation_token: params[:token])
if @user.nil?
return redirect_to root_path, alert: I18n.t('france_connect.particulier.flash.user_not_found')
end
if user_signed_in? && current_user != @user
sign_out :user
redirect_to new_user_session_path, alert: I18n.t('france_connect.particulier.flash.redirect_new_user_session')
end
end
def destination_path(user) = stored_location_for(user) || root_path(user)
def securely_retrieve_fci_from_email_merge_token def securely_retrieve_fci_from_email_merge_token
@fci = FranceConnectInformation.find_by(email_merge_token: email_merge_token_params) @fci = FranceConnectInformation.find_by(email_merge_token: params[:email_merge_token])
if @fci.nil? || !@fci.valid_for_email_merge? if @fci.nil? || !@fci.valid_for_email_merge?
flash.alert = t('france_connect.particulier.flash.merger_token_expired', application_name: Current.application_name) flash.alert = I18n.t('france_connect.particulier.flash.merger_token_expired', application_name: Current.application_name)
redirect_to root_path redirect_to root_path
else else
@ -128,10 +153,10 @@ class FranceConnect::ParticulierController < ApplicationController
end end
def securely_retrieve_fci def securely_retrieve_fci
@fci = FranceConnectInformation.find_by(merge_token: merge_token_params) @fci = FranceConnectInformation.find_by(merge_token: params[:merge_token])
if @fci.nil? || !@fci.valid_for_merge? if @fci.nil? || !@fci.valid_for_merge?
flash.alert = t('france_connect.particulier.flash.merger_token_expired', application_name: Current.application_name) flash.alert = I18n.t('france_connect.particulier.flash.merger_token_expired', application_name: Current.application_name)
redirect_to root_path redirect_to root_path
end end
@ -143,33 +168,18 @@ class FranceConnect::ParticulierController < ApplicationController
end end
end end
def connect_france_connect_particulier(user) def destroy_fci_and_redirect_to_login(fci)
if user_signed_in? fci.destroy
sign_out :user redirect_to new_user_session_path, alert: t('errors.messages.france_connect.forbidden_html', reset_link: new_user_password_path)
end end
def connect_france_connect_particulier(user)
sign_out :user if user_signed_in?
sign_in user sign_in user
user.update_attribute('loged_in_with_france_connect', User.loged_in_with_france_connects.fetch(:particulier)) user.update_attribute('loged_in_with_france_connect', User.loged_in_with_france_connects.fetch(:particulier))
redirect_to stored_location_for(current_user) || root_path(current_user) redirect_to destination_path(current_user)
end
def redirect_france_connect_error_connection
flash.alert = t('errors.messages.france_connect.connexion')
redirect_to(new_user_session_path)
end
def merge_token_params
params[:merge_token]
end
def email_merge_token_params
params[:email_merge_token]
end
def password_params
params[:password]
end end
def sanitized_email_params def sanitized_email_params

View file

@ -0,0 +1,33 @@
import { ApplicationController } from './application_controller';
export class EmailFranceConnectController extends ApplicationController {
static targets = ['useFranceConnectEmail', 'emailField'];
emailFieldTarget!: HTMLElement;
useFranceConnectEmailTargets!: HTMLInputElement[];
connect() {
this.triggerEmailField();
}
triggerEmailField() {
const checkedTarget = this.useFranceConnectEmailTargets.find(
(target) => target.checked
);
const inputElement = this.emailFieldTarget.querySelector(
'input[type="email"]'
) as HTMLInputElement;
if (checkedTarget && checkedTarget.value === 'false') {
this.emailFieldTarget.classList.remove('hidden');
this.emailFieldTarget.setAttribute('aria-hidden', 'false');
inputElement.setAttribute('required', '');
} else {
this.emailFieldTarget.classList.add('hidden');
this.emailFieldTarget.setAttribute('aria-hidden', 'true');
inputElement.removeAttribute('required');
inputElement.value = '';
}
}
}

View file

@ -36,6 +36,12 @@ class UserMailer < ApplicationMailer
mail(to: email, subject: @subject) mail(to: email, subject: @subject)
end end
def custom_confirmation_instructions(user, token)
@user = user
@token = token
mail(to: @user.email, subject: 'Confirmez votre email')
end
def invite_instructeur(user, reset_password_token) def invite_instructeur(user, reset_password_token)
@reset_password_token = reset_password_token @reset_password_token = reset_password_token
@user = user @user = user
@ -139,7 +145,8 @@ class UserMailer < ApplicationMailer
'france_connect_merge_confirmation', 'france_connect_merge_confirmation',
"new_account_warning", "new_account_warning",
"ask_for_merge", "ask_for_merge",
"invite_instructeur" "invite_instructeur",
"custom_confirmation_instructions"
].include?(action_name) ].include?(action_name)
end end
end end

View file

@ -2,28 +2,41 @@
class FranceConnectInformation < ApplicationRecord class FranceConnectInformation < ApplicationRecord
MERGE_VALIDITY = 15.minutes MERGE_VALIDITY = 15.minutes
CONFIRMATION_EMAIL_VALIDITY = 2.days
belongs_to :user, optional: true belongs_to :user, optional: true
validates :france_connect_particulier_id, presence: true, allow_blank: false, allow_nil: false validates :france_connect_particulier_id, presence: true, allow_blank: false, allow_nil: false
def associate_user!(email) def safely_associate_user!(email)
begin begin
user = User.create!( user = User.create!(
email: email.downcase, email: email.downcase,
password: Devise.friendly_token[0, 20], password: Devise.friendly_token[0, 20],
confirmed_at: Time.zone.now confirmed_at: Time.zone.now
) )
user.after_confirmation
rescue ActiveRecord::RecordNotUnique rescue ActiveRecord::RecordNotUnique
# ignore this exception because we check before is user is nil. # ignore this exception because we check before if user is nil.
# exception can be raised in race conditions, when FranceConnect calls callback 2 times. # exception can be raised in race conditions, when FranceConnect calls callback 2 times.
# At the 2nd call, user is nil but exception is raised at the creation of the user # At the 2nd call, user is nil but exception is raised at the creation of the user
# because the first call has already created a user # because the first call has already created a user
end end
clean_tokens_and_requested_email
update_attribute('user_id', user.id) update_attribute('user_id', user.id)
touch # needed to update updated_at column save!
end
def safely_update_user(user:)
self.user = user
clean_tokens_and_requested_email
save!
end
def send_custom_confirmation_instructions
token = SecureRandom.hex(10)
user.update!(confirmation_token: token, confirmation_sent_at: Time.zone.now)
UserMailer.custom_confirmation_instructions(user, token).deliver_later
end end
def create_merge_token! def create_merge_token!
@ -48,14 +61,18 @@ class FranceConnectInformation < ApplicationRecord
(MERGE_VALIDITY.ago < email_merge_token_created_at) && user_id.nil? (MERGE_VALIDITY.ago < email_merge_token_created_at) && user_id.nil?
end end
def delete_merge_token!
update(merge_token: nil, merge_token_created_at: nil)
end
def delete_email_merge_token! def delete_email_merge_token!
update(email_merge_token: nil, email_merge_token_created_at: nil) update(email_merge_token: nil, email_merge_token_created_at: nil)
end end
def clean_tokens_and_requested_email
self.merge_token = nil
self.merge_token_created_at = nil
self.email_merge_token = nil
self.email_merge_token_created_at = nil
self.requested_email = nil
end
def full_name def full_name
[given_name, family_name].compact.join(" ") [given_name, family_name].compact.join(" ")
end end

View file

@ -65,7 +65,6 @@ class User < ApplicationRecord
# Callback provided by Devise # Callback provided by Devise
def after_confirmation def after_confirmation
update!(email_verified_at: Time.zone.now)
link_invites! link_invites!
end end

View file

@ -1,16 +1,7 @@
%p = form_tag france_connect_particulier_merge_using_password_path, data: { turbo: true }, class: 'mt-2 form fconnect-form', id: 'merge_using_password' do
= t('.already_exists', email: email, application_name: Current.application_name) = hidden_field_tag :merge_token, fci.merge_token, id: dom_id(fci, :fusion_merge_token)
%br .fr-input-group{ class: class_names('fr-input-group--error': wrong_password) }
= t('.fill_in_password') = label_tag :password, t('views.registrations.new.password_label', min_length: 8), class: 'fr-label'
= password_field_tag :password, nil, autocomplete: 'current-password', class: 'mb-1 fr-input'
= form_tag france_connect_particulier_merge_with_existing_account_path, data: { turbo: true, turbo_force: :server }, class: 'mt-2 form fconnect-form' do = submit_tag t('france_connect.particulier.merge.button_merge'), class: 'fr-btn'
= hidden_field_tag :merge_token, merge_token
= hidden_field_tag :email, email
= label_tag :password, t('views.registrations.new.password_label', min_length: 8)
= password_field_tag :password, nil, autocomplete: 'current-password', id: 'password-for-another-account'
.mb-2
= t('views.users.sessions.new.reset_password')
= link_to france_connect_particulier_resend_and_renew_merge_confirmation_path(merge_token: merge_token), method: :post do
= t('france_connect.particulier.merge.link_confirm_by_email')
= button_tag t('.back'), type: 'button', class: 'button secondary', onclick: 'DS.showNewAccount(event);'
= submit_tag t('france_connect.particulier.merge.button_merge'), class: 'button primary'

View file

@ -0,0 +1,40 @@
.fr-container
%h1.text-center.mt-1= t('.choose_email_contact')
%p= t('.intro_html', email: @fci.email_france_connect)
%p= t('.use_email_for_notifications')
.fr-fieldset.fr-w-30v.fr-mt-2w
= form_with url: france_connect_particulier_merge_using_fc_email_path(merge_token: @fci.merge_token), method: :post, data: { controller: 'email-france-connect' } do |f|
= hidden_field_tag :merge_token, @fci.merge_token
%fieldset.fr-fieldset
%legend.fr-fieldset__legend
.fr-fieldset__element
.fr-radio-group
= f.radio_button :use_france_connect_email, true, id: 'use_france_connect_email_yes', class: 'fr-radio', required: true, data: { action: "email-france-connect#triggerEmailField", email_france_connect_target: "useFranceConnectEmail" }
%label.fr-label.fr-text--wrap{ for: 'use_france_connect_email_yes' }
= t('.keep_fc_email_html', email: h(@fci.email_france_connect)).html_safe
.fr-fieldset__element
.fr-radio-group
= f.radio_button :use_france_connect_email, false, id: 'use_france_connect_email_no', class: 'fr-radio', required: true, data: { action: "email-france-connect#triggerEmailField", email_france_connect_target: "useFranceConnectEmail" }
%label.fr-label.fr-text--wrap{ for: 'use_france_connect_email_no' }
= t('.use_another_email')
.fr-fieldset__element.fr-fieldset__element--inline.hidden{ aria: { hidden: true }, data: { email_france_connect_target: "emailField", controller: 'email-input', email_input_url_value: show_email_suggestions_path } }
= f.label :email, t('.alternative_email'), class: "fr-label"
%span.fr-hint-text.mb-1= t('activerecord.attributes.user.hints.email')
= f.email_field :email, class: "fr-input"
.suspect-email.hidden{ data: { "email-input-target": 'ariaRegion'}, aria: { live: 'off' } }
= render Dsfr::AlertComponent.new(title: t('utils.email_suggest.wanna_say'), state: :info, heading_level: :div) do |c|
- c.with_body do
%p{ data: { "email-input-target": 'suggestion'} } exemple@gmail.com &nbsp;?
%p
= button_tag type: 'button', class: 'fr-btn fr-btn--sm fr-mr-3w', data: { action: 'click->email-input#accept'} do
= t('utils.yes')
= button_tag type: 'button', class: 'fr-btn fr-btn--sm', data: { action: 'click->email-input#discard'} do
= t('utils.no')
%div
= f.submit t('.confirm'), class: 'fr-btn'

View file

@ -0,0 +1,12 @@
.fr-container
.fr-col-12.fr-col-md-6.fr-col-offset-md-3
%h1.fr-mt-6w.fr-h2.center= t('.confirmation_sent_by_email')
%p.center{ aria: { hidden: true } }= image_tag("user/confirmation-email.svg", alt: t('views.confirmation.new.image_alt'))
= render Dsfr::AlertComponent.new(title: '', state: :info, heading_level: 'h2', extra_class_names: 'fr-mt-6w fr-mb-3w') do |c|
- c.with_body do
%p= t('.intro_html', email: h(email)).html_safe
%p= t('.click_the_link_in_the_email')
%p.center= link_to t('.continue'), destination_path, class: 'fr-btn'

View file

@ -1,46 +1,42 @@
= content_for :title, "Fusion des comptes FC et #{Current.application_name}" = content_for :title, "Fusion des comptes FC et #{Current.application_name}"
.container .fr-container
%h1.page-title= t('.title', application_name: Current.application_name) %h1.page-title= t('.title', application_name: Current.application_name)
%p= t('.subtitle_html', email: @fci.email_france_connect, application_name: Current.application_name) %p= t('.subtitle_html', email: @fci.email_france_connect, application_name: Current.application_name)
.form.mt-2 %fieldset.fr-fieldset{ aria: { labelledby: 'merge-account' } }
%label= t('.label_select_merge_flow', email: @fci.email_france_connect) %legend.fr-fieldset__legend#merge-account= t('.label_select_merge_flow', email: @fci.email_france_connect)
%fieldset.radios .fr-fieldset__element.fr-fieldset__element--inline
%label{ onclick: "DS.showFusion(event);" } .fr-radio-group
= radio_button_tag :value, true, false, autocomplete: "off", id: 'it-is-mine' %input{ type: 'radio', id: 'it-is-mine', name: 'value', value: 'true', autocomplete: "off", onclick: "DS.showFusion(event);" }
= t('utils.yes') %label{ for: 'it-is-mine' }= t('utils.yes')
.fr-fieldset__element.fr-fieldset__element--inline
.fr-radio-group
%input{ type: 'radio', id: 'it-is-not-mine', name: 'value', value: 'false', autocomplete: "off", onclick: "DS.showNewAccount(event);" }
%label{ for: 'it-is-not-mine' }= t('utils.no')
%label{ onclick: "DS.showNewAccount(event);" }
= radio_button_tag :value, false, false, autocomplete: "off", id: 'it-is-not-mine'
= t('utils.no')
.fusion.hidden .fusion.hidden
%p= t('.title_fill_in_password') %p= t('.title_fill_in_password')
= form_tag france_connect_particulier_merge_with_existing_account_path, data: { turbo: true }, class: 'mt-2 form fconnect-form' do = render partial: 'password_confirmation', locals: { fci: @fci, wrong_password: @wrong_password }
= hidden_field_tag :merge_token, @fci.merge_token, id: dom_id(@fci, :fusion_merge_token)
= hidden_field_tag :email, @fci.email_france_connect, id: dom_id(@fci, :fusion_email)
.fr-input-group
= label_tag :password, t('views.registrations.new.password_label', min_length: 8), class: 'fr-label'
= password_field_tag :password, nil, autocomplete: 'current-password', class: 'mb-1 fr-input'
.mb-2
= t('views.users.sessions.new.reset_password')
= link_to france_connect_particulier_resend_and_renew_merge_confirmation_path(merge_token: @fci.merge_token), method: :post do
= t('.link_confirm_by_email')
= submit_tag t('.button_merge'), class: 'fr-btn' .mt-2
= button_to t('.link_confirm_by_email'),
france_connect_particulier_send_email_merge_request_path,
params: { email: @fci.email_france_connect, merge_token: @fci.merge_token },
class: 'fr-btn fr-btn--secondary'
.new-account.hidden .new-account.hidden
%p= t('.title_fill_in_email', application_name: Current.application_name) %p= t('.title_fill_in_email', application_name: Current.application_name)
= form_tag france_connect_particulier_merge_with_new_account_path, data: { turbo: true }, class: 'mt-2 form' do = form_tag france_connect_particulier_send_email_merge_request_path, class: 'mt-2 form' do
= hidden_field_tag :merge_token, @fci.merge_token, id: dom_id(@fci, :new_account_merge_token) = hidden_field_tag :merge_token, @fci.merge_token, id: dom_id(@fci, :new_account_merge_token)
= label_tag :email, t('views.registrations.new.email_label'), for: dom_id(@fci, :new_account_email) = label_tag :email, t('views.registrations.new.email_label'), for: dom_id(@fci, :new_account_email), class: 'fr-label'
= email_field_tag :email, "", required: true, id: dom_id(@fci, :new_account_email) = email_field_tag :email, "", required: true, id: dom_id(@fci, :new_account_email), class: 'mb-1 fr-input'
= submit_tag t('.button_use_this_email'), class: 'button primary' = submit_tag t('.button_use_this_email'), class: 'fr-btn'
#new-account-password-confirmation.hidden #new-account-password-confirmation.hidden

View file

@ -0,0 +1 @@
= turbo_stream.replace('merge_using_password', partial: 'password_confirmation', locals: { fci: @fci, wrong_password: true })

View file

@ -1,4 +0,0 @@
= turbo_stream.update 'new-account-password-confirmation', partial: 'password_confirmation', locals: { email: @email, merge_token: @merge_token }
= turbo_stream.hide_all '.fusion'
= turbo_stream.hide_all '.new-account'
= turbo_stream.show 'new-account-password-confirmation'

View file

@ -0,0 +1,22 @@
- content_for(:title, 'Confirmez votre email')
%p
Bonjour
= @user.email
!
%p
Veuillez confirmer votre email en cliquant sur le lien ci-dessous:
= round_button 'Je confirme', france_connect_confirm_email_url(@token), :primary
%p Ce lien est valide #{distance_of_time_in_words(FranceConnectInformation::CONFIRMATION_EMAIL_VALIDITY)}.
%p
Tant que vous n'aurez pas confirmé votre email, vous ne recevrez aucune notification sur l'avancement de vos dossiers.
%p
Si vous nêtes pas à lorigine de cette demande, vous pouvez ignorer ce message. Et si vous avez besoin dassistance, nhésitez pas à nous contacter à
= succeed '.' do
= mail_to CONTACT_EMAIL
= render partial: "layouts/mailers/signature"

View file

@ -1,5 +1,5 @@
- content_for(:title, @subject) - content_for(:title, @subject)
- merge_link = france_connect_particulier_mail_merge_with_existing_account_url(email_merge_token: @email_merge_token) - merge_link = france_connect_particulier_merge_using_email_link_url(email_merge_token: @email_merge_token)
%p %p
Bonjour, Bonjour,

View file

@ -47,6 +47,9 @@ en:
utils: utils:
'yes': 'Yes' 'yes': 'Yes'
'no': 'No' 'no': 'No'
email_suggest:
wanna_say: 'Do you mean to say ?'
deconnexion: "Log out" deconnexion: "Log out"
pj: "Attachments" pj: "Attachments"
asterisk_html: "Fields marked by an asterisk ( <svg aria-label='required' class='icon mandatory' height='10' role='img' viewBox='0 0 1200 1200' width='10' xml:space='preserve' xmlns='http://www.w3.org/2000/svg'><desc>required</desc><path d='M489.838 29.354v443.603L68.032 335.894 0 545.285l421.829 137.086-260.743 358.876 178.219 129.398L600.048 811.84l260.673 358.806 178.146-129.398-260.766-358.783L1200 545.379l-68.032-209.403-421.899 137.07V29.443H489.84l-.002-.089z'></path></svg> ) are mandatory." asterisk_html: "Fields marked by an asterisk ( <svg aria-label='required' class='icon mandatory' height='10' role='img' viewBox='0 0 1200 1200' width='10' xml:space='preserve' xmlns='http://www.w3.org/2000/svg'><desc>required</desc><path d='M489.838 29.354v443.603L68.032 335.894 0 545.285l421.829 137.086-260.743 358.876 178.219 129.398L600.048 811.84l260.673 358.806 178.146-129.398-260.766-358.783L1200 545.379l-68.032-209.403-421.899 137.07V29.443H489.84l-.002-.089z'></path></svg> ) are mandatory."
@ -150,6 +153,8 @@ en:
subtitle_two: "Additional notes" subtitle_two: "Additional notes"
content_html: "<p class=\"fr-mb-2w\">The documentation pages are managed by a third-party tool. They are not fully accessible.</p> content_html: "<p class=\"fr-mb-2w\">The documentation pages are managed by a third-party tool. They are not fully accessible.</p>
<p class=\"fr-mb-2w\">FAQ management was delegated to a third-party tool. It was reintegrated into the platform in May 2024 and has not yet been audited.</p>" <p class=\"fr-mb-2w\">FAQ management was delegated to a third-party tool. It was reintegrated into the platform in May 2024 and has not yet been audited.</p>"
preparation: preparation:
title: "Preparation of this accessibility declaration" title: "Preparation of this accessibility declaration"
intro: "This declaration was drawn up on 27 April 2022. It was updated on 14 June 2024." intro: "This declaration was drawn up on 27 April 2022. It was updated on 14 June 2024."
@ -746,6 +751,7 @@ en:
# # etablissement_fail: 'Désolé, nous navons pas réussi à enregistrer létablissement correspondant à ce numéro SIRET' # # etablissement_fail: 'Désolé, nous navons pas réussi à enregistrer létablissement correspondant à ce numéro SIRET'
france_connect: france_connect:
connexion: "Error trying to connect to France Connect." connexion: "Error trying to connect to France Connect."
forbidden_html: "Only citizen can use FranceConnect. As an instructor or administrator, you should <a href='%{reset_link}'>reset your password</a>."
evil_regexp: The regular expression you have entered is potentially dangerous and could lead to performance issues. evil_regexp: The regular expression you have entered is potentially dangerous and could lead to performance issues.
mismatch_regexp: The provided example must match the regular expression mismatch_regexp: The provided example must match the regular expression
syntax_error_regexp: The syntax of the regular expression is invalid syntax_error_regexp: The syntax of the regular expression is invalid
@ -879,10 +885,19 @@ en:
to_follow: to follow to_follow: to follow
france_connect: france_connect:
particulier: particulier:
password_confirmation: choose_email:
back: 'back to previous step' intro_html: "Your FranceConnect account uses <span class='fr-badge fr-badge--info fr-badge--sm'>%{email}</span> as the contact email."
already_exists: An account with %{email} already existis on %{application_name} use_email_for_notifications: "Would you like to use it to receive notifications regarding the progress of your cases?"
fill_in_password: fill in your password to merge your accounts confirm: "Confirm"
choose_email_contact: "Choose your contact email"
alternative_email: "Please provide the email to use for contacting you."
keep_fc_email_html: Yes, use <b class='bold'>%{email}</b> as contact email.
use_another_email: No, use another address.
confirmation_sent:
confirmation_sent_by_email: "Confirm your email"
intro_html: "A confirmation email has been sent to your address <span class='fr-badge fr-badge--info fr-badge--sm'>%{email}</span>"
click_the_link_in_the_email: "Please click the link in the email to confirm your account and connect with France Connect in the future."
continue: "Continue"
merge: merge:
title: "Merge your account FranceConnect and %{application_name}" title: "Merge your account FranceConnect and %{application_name}"
subtitle_html: "Hello,<br /><br />Your account FranceConnect uses <b class='bold'>%{email}</b> as contact email.<br />But there is an existing %{application_name} account using this email." subtitle_html: "Hello,<br /><br />Your account FranceConnect uses <b class='bold'>%{email}</b> as contact email.<br />But there is an existing %{application_name} account using this email."
@ -894,6 +909,11 @@ en:
link_confirm_by_email: Confirm by receiving an email link_confirm_by_email: Confirm by receiving an email
flash: flash:
confirmation_mail_sent: "An email with the confirmation link has been sent, please click on the link." confirmation_mail_sent: "An email with the confirmation link has been sent, please click on the link."
confirmation_mail_resent: "Confirmation link expired. A new link has been sent by email."
confirmation_mail_resent_error: "An unexpected error has occurred. Please contact support if the problem persists."
redirect_new_user_session: "You have been disconnected from your previous account. Please click on the confirmation link again."
email_confirmed: "Your email is confirmed"
user_not_found: "User not found"
invalid_password: "The password is not correct." invalid_password: "The password is not correct."
connection_done: "The accounts for FranceConnect and %{application_name} are now merged." connection_done: "The accounts for FranceConnect and %{application_name} are now merged."
merger_token_expired: "Le delay to merge your FranceConnect and %{application_name} accounts is expired. Please retry." merger_token_expired: "Le delay to merge your FranceConnect and %{application_name} accounts is expired. Please retry."

View file

@ -37,6 +37,9 @@ fr:
utils: utils:
'yes': Oui 'yes': Oui
'no': Non 'no': Non
email_suggest:
wanna_say: 'Voulez-vous dire ?'
i_dont_know: Je ne sais pas i_dont_know: Je ne sais pas
deconnexion: "Déconnexion" deconnexion: "Déconnexion"
pj: "Pièces jointes" pj: "Pièces jointes"
@ -935,10 +938,21 @@ fr:
ministeres: Ministères ministeres: Ministères
france_connect: france_connect:
particulier: particulier:
password_confirmation: choose_email:
back: 'revenir en arrière' intro_html: "Votre compte FranceConnect utilise <span class='fr-badge fr-badge--info fr-badge--sm'>%{email}</span> comme email de contact."
already_exists: Le compte %{email} existe déjà sur %{application_name} use_email_for_notifications: Souhaitez-vous l'utiliser pour recevoir les notifications concernant l'avancement de vos dossiers ?
fill_in_password: entrez votre mot de passe pour fusionner les comptes confirm: Confirmer
choose_email_contact: Choisissez votre email de contact pour finaliser votre connexion
alternative_email: Veuillez nous fournir l'email à utiliser pour vous contacter.
keep_fc_email_html: "Oui, utiliser %{email} comme email de contact."
use_another_email: Non, utiliser une autre adresse.
email_suggest:
wanna_say: 'Voulez-vous dire ?'
confirmation_sent:
confirmation_sent_by_email: Confirmez votre email
intro_html: "Un mail de confirmation a été envoyé à votre adresse <span class='fr-badge fr-badge--info fr-badge--sm'>%{email}</span>"
click_the_link_in_the_email: Vous devez impérativement cliquer sur le lien du mail pour activer votre adresse et recevoir les notifications sur l'avancement de vos dossiers.
continue: Continuer
merge: merge:
title: "Fusion des comptes FranceConnect et %{application_name}" title: "Fusion des comptes FranceConnect et %{application_name}"
subtitle_html: "Bonjour,<br /><br />Votre compte FranceConnect utilise <b class='bold'>%{email}</b> comme email de contact.<br />Or il existe un compte sur %{application_name} avec cet email." subtitle_html: "Bonjour,<br /><br />Votre compte FranceConnect utilise <b class='bold'>%{email}</b> comme email de contact.<br />Or il existe un compte sur %{application_name} avec cet email."
@ -950,7 +964,12 @@ fr:
link_confirm_by_email: Confirmer mon compte par email link_confirm_by_email: Confirmer mon compte par email
flash: flash:
confirmation_mail_sent: "Nous venons de vous envoyer le mail de confirmation, veuillez cliquer sur le lien contenu dans ce mail pour fusionner vos comptes" confirmation_mail_sent: "Nous venons de vous envoyer le mail de confirmation, veuillez cliquer sur le lien contenu dans ce mail pour fusionner vos comptes"
invalid_password: "Mauvais mot de passe" confirmation_mail_resent: "Le lien de confirmation a expiré. Un nouveau lien de confirmation vous a été envoyé par email."
confirmation_mail_resent_error: "Une erreur inattendue est survenue. Veuillez contacter le support si le problème persiste."
redirect_new_user_session: "Vous avez été déconnecté de votre précédent compte. Veuillez cliquer à nouveau sur le lien de confirmation."
email_confirmed: 'Votre email est bien vérifié'
user_not_found: 'Utilisateur non trouvé'
invalid_password: "Mot de passe incorrect"
connection_done: "Les comptes FranceConnect et %{application_name} sont à présent fusionnés" connection_done: "Les comptes FranceConnect et %{application_name} sont à présent fusionnés"
merger_token_expired: "Le délai pour fusionner les comptes FranceConnect et %{application_name} est expiré. Veuillez recommencer la procédure pour vous fusionner les comptes." merger_token_expired: "Le délai pour fusionner les comptes FranceConnect et %{application_name} est expiré. Veuillez recommencer la procédure pour vous fusionner les comptes."
groupe_gestionnaires: groupe_gestionnaires:

View file

@ -188,11 +188,14 @@ Rails.application.routes.draw do
namespace :france_connect do namespace :france_connect do
get 'particulier' => 'particulier#login' get 'particulier' => 'particulier#login'
get 'particulier/callback' => 'particulier#callback' get 'particulier/callback' => 'particulier#callback'
get 'particulier/merge/:merge_token' => 'particulier#merge', as: :particulier_merge
get 'particulier/mail_merge_with_existing_account/:email_merge_token' => 'particulier#mail_merge_with_existing_account', as: :particulier_mail_merge_with_existing_account post 'particulier/send_email_merge_request'
post 'particulier/resend_and_renew_merge_confirmation' => 'particulier#resend_and_renew_merge_confirmation', as: :particulier_resend_and_renew_merge_confirmation
post 'particulier/merge_with_existing_account' => 'particulier#merge_with_existing_account' post 'particulier/merge_using_fc_email'
post 'particulier/merge_with_new_account' => 'particulier#merge_with_new_account' post 'particulier/merge_using_password'
get 'particulier/merge_using_email_link/:email_merge_token' => 'particulier#merge_using_email_link', as: :particulier_merge_using_email_link
get 'confirm_email/:token', to: 'particulier#confirm_email', as: :confirm_email
end end
namespace :agent_connect do namespace :agent_connect do

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddAlternativeEmailColumnToFranceConnectInformationTable < ActiveRecord::Migration[7.0]
def change
add_column :france_connect_informations, :requested_email, :string
end
end

View file

@ -712,6 +712,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_08_29_141049) do
t.string "given_name" t.string "given_name"
t.string "merge_token" t.string "merge_token"
t.datetime "merge_token_created_at", precision: nil t.datetime "merge_token_created_at", precision: nil
t.string "requested_email"
t.datetime "updated_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false
t.integer "user_id" t.integer "user_id"
t.index ["email_merge_token"], name: "index_france_connect_informations_on_email_merge_token" t.index ["email_merge_token"], name: "index_france_connect_informations_on_email_merge_token"

View file

@ -89,39 +89,25 @@ describe FranceConnect::ParticulierController, type: :controller do
let(:fc_user) { nil } let(:fc_user) { nil }
context 'and no user with the same email exists' do context 'and no user with the same email exists' do
it 'creates an user with the same email and log in' do it 'render the choose email template to select good email' do
expect { subject }.to change { User.count }.by(1) expect { subject }.to change { User.count }.by(0)
expect(subject).to render_template(:choose_email)
user = User.last
expect(user.email).to eq(email.downcase)
expect(controller.current_user).to eq(user)
expect(response).to redirect_to(root_path)
end
context 'when invites are pending' do
let!(:invite) { create(:invite, email: email, user: nil) }
it 'links pending invites' do
expect(invite.reload.user).to eq(nil)
subject
expect(invite.reload.user).to eq(User.last)
end
end end
end end
context 'and an user with the same email exists' do context 'and an user with the same email exists' do
let!(:preexisting_user) { create(:user, email: email) } let!(:preexisting_user) { create(:user, email: email) }
it 'redirects to the merge process' do it 'renders the merge page' do
expect { subject }.not_to change { User.count } expect { subject }.not_to change { User.count }
expect(response).to redirect_to(france_connect_particulier_merge_path(fci.reload.merge_token)) expect(response).to render_template(:merge)
end end
end end
context 'and an instructeur with the same email exists' do context 'and an instructeur with the same email exists' do
let!(:preexisting_user) { create(:instructeur, email: email) } let!(:preexisting_user) { create(:instructeur, email: email) }
it 'redirects to the merge process' do it 'redirects to the login path' do
expect { subject }.not_to change { User.count } expect { subject }.not_to change { User.count }
expect(response).to redirect_to(new_user_session_path) expect(response).to redirect_to(new_user_session_path)
@ -134,15 +120,7 @@ describe FranceConnect::ParticulierController, type: :controller do
context 'when france_connect_particulier_id does not exist in database' do context 'when france_connect_particulier_id does not exist in database' do
it { expect { subject }.to change { FranceConnectInformation.count }.by(1) } it { expect { subject }.to change { FranceConnectInformation.count }.by(1) }
describe 'FranceConnectInformation attributs' do it { is_expected.to render_template(:choose_email) }
let(:stored_fci) { FranceConnectInformation.last }
before { subject }
it { expect(stored_fci).to have_attributes(user_info.merge(birthdate: Time.zone.parse(birthdate).to_datetime)) }
end
it { is_expected.to redirect_to(root_path) }
end end
end end
@ -158,6 +136,167 @@ describe FranceConnect::ParticulierController, type: :controller do
end end
end end
describe '#merge_using_fc_email' do
subject { post :merge_using_fc_email, params: { merge_token: merge_token } }
let!(:fci) { FranceConnectInformation.create!(user_info) }
let(:merge_token) { fci.create_merge_token! }
before do
allow(UserMailer).to receive_message_chain(:custom_confirmation_instructions, :deliver_later)
end
context 'when the merge token is valid' do
it do
expect(User.last.email).not_to eq(email.downcase)
subject
user = User.last
expect(user.email).to eq(email.downcase)
expect(UserMailer).to have_received(:custom_confirmation_instructions).with(user, user.confirmation_token)
expect(user.email_verified_at).to be_nil
expect(fci.reload.merge_token).to be_nil
expect(response).to render_template(:confirmation_sent)
end
end
context 'when the merge token is invalid' do
let(:merge_token) { 'invalid_token' }
it 'redirects to root_path with an alert' do
subject
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to eq("Le délai pour fusionner les comptes FranceConnect et demarches-simplifiees.fr est expiré. Veuillez recommencer la procédure pour vous fusionner les comptes.")
end
end
context 'when @fci is not valid for merge' do
before do
merge_token
fci.update!(merge_token_created_at: 2.years.ago)
end
it 'redirects to root_path with an alert' do
subject
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to eq('Le délai pour fusionner les comptes FranceConnect et demarches-simplifiees.fr est expiré. Veuillez recommencer la procédure pour vous fusionner les comptes.')
end
end
end
describe '#confirm_email' do
let!(:user) { create(:user) }
let!(:fci) { create(:france_connect_information, user: user) }
before { fci.send_custom_confirmation_instructions }
context 'when the confirmation token is valid' do
before do
get :confirm_email, params: { token: user.confirmation_token }
user.reload
end
it do
expect(user.email_verified_at).to be_present
expect(user.confirmation_token).to be_nil
expect(response).to redirect_to(root_path(user))
expect(flash[:notice]).to eq('Votre email est bien vérifié')
end
end
context 'when invites are pending' do
let!(:invite) { create(:invite, email: user.email, user: nil) }
it 'links pending invites' do
get :confirm_email, params: { token: user.confirmation_token }
invite.reload
expect(invite.user).to eq(user)
end
end
context 'when the confirmation token is expired' do
let!(:expired_user_confirmation) do
create(:user, confirmation_token: 'expired_token', confirmation_sent_at: 3.days.ago)
end
it 'redirects to root path with an alert when FranceConnectInformation is not found' do
get :confirm_email, params: { token: 'expired_token' }
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to eq(I18n.t('france_connect.particulier.flash.confirmation_mail_resent_error'))
end
context 'when FranceConnectInformation exists' do
let!(:france_connect_information) do
create(:france_connect_information, user: expired_user_confirmation)
end
before do
allow(UserMailer).to receive_message_chain(:custom_confirmation_instructions, :deliver_later)
end
it 'resends the confirmation email and redirects to root path with a notice' do
get :confirm_email, params: { token: 'expired_token' }
expect(UserMailer).to have_received(:custom_confirmation_instructions)
.with(expired_user_confirmation, expired_user_confirmation.reload.confirmation_token)
expect(response).to redirect_to(root_path)
expect(flash[:notice]).to eq(I18n.t('france_connect.particulier.flash.confirmation_mail_resent'))
end
end
end
context 'when a different user is signed in' do
let!(:expired_user_confirmation) do
create(:user, confirmation_token: 'expired_token', confirmation_sent_at: 3.days.ago)
end
let(:another_user) { create(:user) }
before { sign_in(another_user) }
it 'signs out the current user and redirects to sign in path' do
expect_any_instance_of(FranceConnectInformation).not_to receive(:send_custom_confirmation_instructions)
expect(controller).to receive(:sign_out).with(:user)
get :confirm_email, params: { token: 'expired_token' }
expect(response).to redirect_to(new_user_session_path)
expect(flash[:alert]).to eq(I18n.t('france_connect.particulier.flash.redirect_new_user_session'))
end
end
end
describe '#set_user_by_confirmation_token' do
let(:current_user) { create(:user) }
let!(:confirmation_user) { create(:user, confirmation_token: 'valid_token') }
before { sign_in current_user }
it 'signs out current user and redirects to new session path when users do not match' do
expect(controller).to receive(:sign_out).with(:user)
get :confirm_email, params: { token: 'valid_token' }
expect(response).to redirect_to(new_user_session_path)
expect(flash[:alert]).to eq(I18n.t('france_connect.particulier.flash.redirect_new_user_session'))
end
context 'when user is not found' do
it 'redirects to root path with user not found alert' do
get :confirm_email, params: { token: 'invalid_token' }
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to eq(I18n.t('france_connect.particulier.flash.user_not_found'))
end
end
end
RSpec.shared_examples "a method that needs a valid merge token" do RSpec.shared_examples "a method that needs a valid merge token" do
context 'when the merge token is invalid' do context 'when the merge token is invalid' do
before do before do
@ -178,41 +317,14 @@ describe FranceConnect::ParticulierController, type: :controller do
end end
end end
describe '#merge' do describe '#merge_using_password' do
let(:fci) { FranceConnectInformation.create!(user_info) }
let(:merge_token) { fci.create_merge_token! }
let(:format) { :html }
subject { get :merge, params: { merge_token: merge_token } }
context 'when the merge token is valid' do
it { expect(subject).to have_http_status(:ok) }
end
it_behaves_like "a method that needs a valid merge token"
context 'when the merge token does not exist' do
let(:merge_token) { 'i do not exist' }
before do
allow(Current).to receive(:application_name).and_return('demarches-simplifiees.fr')
end
it do
expect(subject).to redirect_to root_path
expect(flash.alert).to eq("Le délai pour fusionner les comptes FranceConnect et demarches-simplifiees.fr est expiré. Veuillez recommencer la procédure pour vous fusionner les comptes.")
end
end
end
describe '#merge_with_existing_account' do
let(:fci) { FranceConnectInformation.create!(user_info) } let(:fci) { FranceConnectInformation.create!(user_info) }
let(:merge_token) { fci.create_merge_token! } let(:merge_token) { fci.create_merge_token! }
let(:email) { 'EXISTING_account@a.com ' } let(:email) { 'EXISTING_account@a.com ' }
let(:password) { SECURE_PASSWORD } let(:password) { SECURE_PASSWORD }
let(:format) { :turbo_stream } let(:format) { :turbo_stream }
subject { post :merge_with_existing_account, params: { merge_token: merge_token, email: email, password: password }, format: format } subject { post :merge_using_password, params: { merge_token: merge_token, password: password }, format: format }
it_behaves_like "a method that needs a valid merge token" it_behaves_like "a method that needs a valid merge token"
@ -244,9 +356,9 @@ describe FranceConnect::ParticulierController, type: :controller do
it 'redirects to the root page' do it 'redirects to the root page' do
subject subject
fci.reload
expect(fci.user).to be_nil expect { fci.reload }.to raise_error(ActiveRecord::RecordNotFound)
expect(fci.merge_token).not_to be_nil expect(fci.merge_token).not_to be_nil
expect(controller.current_user).to be_nil expect(controller.current_user).to be_nil
end end
@ -268,18 +380,21 @@ describe FranceConnect::ParticulierController, type: :controller do
end end
end end
describe '#mail_merge_with_existing_account' do describe '#merge_using_email_link' do
let(:fci) { FranceConnectInformation.create!(user_info) } let(:fci) { FranceConnectInformation.create!(user_info) }
let!(:email_merge_token) { fci.create_email_merge_token! } let!(:email_merge_token) { fci.create_email_merge_token! }
context 'when the merge_token is ok and the user is found' do context 'when the merge_token is ok and the user is found' do
subject { post :mail_merge_with_existing_account, params: { email_merge_token: } } subject do
post :merge_using_email_link, params: { email_merge_token: }
end
before do before do
allow(Current).to receive(:application_name).and_return('demarches-simplifiees.fr') allow(Current).to receive(:application_name).and_return('demarches-simplifiees.fr')
fci.update!(requested_email: email.downcase)
end end
let!(:user) { create(:user, email: email, password: 'abcdefgh') } let!(:user) { create(:user, email:, password: 'abcdefgh') }
it 'merges the account, signs in, and delete the merge token' do it 'merges the account, signs in, and delete the merge token' do
subject subject
@ -304,82 +419,26 @@ describe FranceConnect::ParticulierController, type: :controller do
end end
end end
end end
context 'when the email_merge_token is not ok' do
subject { post :mail_merge_with_existing_account, params: { email_merge_token: 'ko' } }
let!(:user) { create(:user, email: email) }
it 'increases the failed attempts counter' do
subject
fci.reload
expect(fci.user).to be_nil
expect(fci.email_merge_token).not_to be_nil
expect(controller.current_user).to be_nil
expect(response).to redirect_to(root_path)
end
end
end end
describe '#merge_with_new_account' do describe '#send_email_merge_request' do
let(:fci) { FranceConnectInformation.create!(user_info) } let(:fci) { FranceConnectInformation.create!(user_info) }
let(:merge_token) { fci.create_merge_token! } let(:merge_token) { fci.create_merge_token! }
let(:email) { ' Account@a.com ' } let(:email) { 'requested_email@.a.com' }
let(:format) { :turbo_stream }
subject { post :merge_with_new_account, params: { merge_token: merge_token, email: email }, format: format } subject { post :send_email_merge_request, params: { merge_token: merge_token, email: } }
it_behaves_like "a method that needs a valid merge token"
context 'when the email does not belong to any user' do
it 'creates the account, signs in, and delete the merge token' do
subject
fci.reload
expect(fci.user.email).to eq(email.downcase.strip)
expect(fci.merge_token).to be_nil
expect(controller.current_user).to eq(fci.user)
expect(response).to redirect_to(root_path)
end
end
context 'when an account with the same email exists' do
let!(:user) { create(:user, email: email) }
before { allow(controller).to receive(:sign_in).and_call_original }
render_views
it 'asks for the corresponding password' do
subject
fci.reload
expect(fci.user).to be_nil
expect(fci.merge_token).not_to be_nil
expect(controller.current_user).to be_nil
expect(response.body).to include('entrez votre mot de passe')
end
it 'cannot use the merge token in the email confirmation route' do
subject
fci.reload
get :mail_merge_with_existing_account, params: { email_merge_token: fci.merge_token }
expect(controller).not_to have_received(:sign_in)
expect(flash[:alert]).to be_present
end
end
end
describe '#resend_and_renew_merge_confirmation' do
let(:fci) { FranceConnectInformation.create!(user_info) }
let(:merge_token) { fci.create_merge_token! }
it 'renew token' do it 'renew token' do
expect { post :resend_and_renew_merge_confirmation, params: { merge_token: merge_token } }.to change { fci.reload.merge_token } allow(UserMailer).to receive_message_chain(:france_connect_merge_confirmation, :deliver_later)
subject
fci.reload
expect(fci.requested_email).to eq(email)
expect(fci.email_merge_token).to be_present expect(fci.email_merge_token).to be_present
expect(response).to redirect_to(france_connect_particulier_merge_path(fci.reload.merge_token))
expect(UserMailer).to have_received(:france_connect_merge_confirmation).with(email, fci.email_merge_token, fci.email_merge_token_created_at)
expect(response).to redirect_to(root_path)
end end
end end
end end

View file

@ -81,7 +81,7 @@ RSpec.describe UserMailer, type: :mailer do
it 'sends to correct email with merge link' do it 'sends to correct email with merge link' do
expect(subject.to).to eq([email]) expect(subject.to).to eq([email])
expect(subject.body).to include(france_connect_particulier_mail_merge_with_existing_account_url(email_merge_token: code)) expect(subject.body).to include(france_connect_particulier_merge_using_email_link_url(email_merge_token: code))
end end
context 'without SafeMailer configured' do context 'without SafeMailer configured' do
@ -101,6 +101,31 @@ RSpec.describe UserMailer, type: :mailer do
end end
end end
describe '#custom_confirmation_instructions' do
let(:user) { create(:user, email: 'user@example.com') }
let(:token) { 'confirmation_token_123' }
let(:mail) { UserMailer.custom_confirmation_instructions(user, token) }
it 'renders the headers' do
expect(mail.subject).to eq('Confirmez votre email')
expect(mail.to).to eq([user.email])
expect(mail.from).to eq(['contact@demarches-simplifiees.fr'])
end
it 'renders the body' do
expect(mail.body.encoded).to match(user.email)
expect(mail.body.encoded).to match(token)
end
it 'assigns @user' do
expect(mail.body.encoded).to match(user.email)
end
it 'assigns @token' do
expect(mail.body.encoded).to include(token)
end
end
describe '.send_archive' do describe '.send_archive' do
let(:procedure) { create(:procedure) } let(:procedure) { create(:procedure) }
let(:archive) { create(:archive) } let(:archive) { create(:archive) }

View file

@ -9,19 +9,67 @@ describe FranceConnectInformation, type: :model do
end end
end end
describe 'associate_user!' do describe 'safely_associate_user!' do
context 'when there is no user with same email' do let(:email) { 'A@email.com' }
let(:email) { 'A@email.com' } let(:fci) { build(:france_connect_information) }
let(:fci) { build(:france_connect_information) }
subject { fci.associate_user!(email) } subject { fci.safely_associate_user!(email) }
it { expect { subject }.to change(User, :count).by(1) } context 'when there is no user with the same email' do
it 'creates a new user' do
expect { subject }.to change(User, :count).by(1)
end
it do it 'sets the correct attributes on the user' do
subject subject
expect(fci.user.email).to eq('a@email.com') user = User.find_by(email: email.downcase)
expect(fci.user.email_verified_at).to be_present expect(user).not_to be_nil
expect(user.confirmed_at).to be_present
end
it 'associates the user with the FranceConnectInformation' do
subject
expect(fci.reload.user.email).to eq(email.downcase)
end
end
context 'when a user with the same email already exists due to race condition' do
let!(:existing_user) { create(:user, email: email.downcase) }
let!(:fci) { create(:france_connect_information) } # Assurez-vous que fci est créé et sauvegardé
before do
call_count = 0
allow(User).to receive(:create!).and_wrap_original do
call_count += 1
if call_count == 1
raise ActiveRecord::RecordNotUnique
else
existing_user
end
end
allow(fci).to receive(:send_custom_confirmation_instructions)
end
it 'raises an error' do
expect { fci.safely_associate_user!(email) }.to raise_error(NoMethodError)
end
it 'does not create a new user' do
expect {
begin
fci.safely_associate_user!(email)
rescue NoMethodError
end
}.to_not change(User, :count)
end
it 'does not associate with any user' do
expect(fci.user).to be_nil
begin
fci.safely_associate_user!(email)
rescue NoMethodError
end
expect(fci.reload.user).to be_nil
end end
end end
end end

View file

@ -18,12 +18,6 @@ describe User, type: :model do
user.confirm user.confirm
expect(user.reload.invites.size).to eq(2) expect(user.reload.invites.size).to eq(2)
end end
it 'verifies its email' do
expect(user.email_verified_at).to be_nil
user.confirm
expect(user.email_verified_at).to be_present
end
end end
describe '#owns?' do describe '#owns?' do

View file

@ -43,9 +43,25 @@ describe 'France Connect Particulier Connexion' do
before { page.find('.fr-connect').click } before { page.find('.fr-connect').click }
scenario 'he is redirected to user dossiers page' do scenario 'he is redirected to user dossiers page' do
expect(page).to have_content('Dossiers') expect(page).to have_content("Choisissez votre email de contact pour finaliser votre connexion")
find("#use_france_connect_email_no").click
fill_in("email", with: "exemple@email.com")
page.find("input[type='submit'][name='commit'][value='Confirmer']").click
expect(page).to have_content("Confirmez votre email")
click_on 'Continuer'
expect(User.find_by(email: email)).not_to be nil expect(User.find_by(email: email)).not_to be nil
end end
scenario 'he can choose not to use FranceConnect email and input an alternative email' do
expect(page).to have_content("Choisissez votre email de contact pour finaliser votre connexion")
expect(page).to have_selector("input[name='email']", visible: true, wait: 10)
fill_in 'email', with: 'alternative@example.com'
click_on 'Confirmer'
expect(page).to have_content("Confirmez votre email")
end
end end
context 'and an user exists with the same email' do context 'and an user exists with the same email' do
@ -72,25 +88,23 @@ describe 'France Connect Particulier Connexion' do
fill_in 'email', with: 'new_email@a.com' fill_in 'email', with: 'new_email@a.com'
click_on 'Utiliser ce mail' click_on 'Utiliser ce mail'
expect(page).to have_content('Dossiers') expect(page).to have_content('Nous venons de vous envoyer le mail de confirmation')
end end
context 'and the user wants an email that belongs to another account', js: true do context 'and the user wants an email that belongs to another account', js: true do
let!(:another_user) { create(:user, email: 'an_existing_email@a.com', password: SECURE_PASSWORD) } let!(:another_user) { create(:user, email: 'an_existing_email@a.com', password: SECURE_PASSWORD) }
scenario 'it uses another email that belongs to another account' do scenario 'it uses another email that belongs to another account' do
page.find('#it-is-not-mine').click find('label[for="it-is-not-mine"]').click
fill_in 'email', with: 'an_existing_email@a.com'
click_on 'Utiliser ce mail'
expect(page).to have_css('#password-for-another-account', visible: true) expect(page).to have_css('.new-account', visible: true)
within '#new-account-password-confirmation' do within '.new-account' do
fill_in 'password', with: SECURE_PASSWORD fill_in 'email', with: 'an_existing_email@a.com'
click_on 'Fusionner les comptes' click_on 'Utiliser ce mail'
end end
expect(page).to have_content('Dossiers') expect(page).to have_content('Nous venons de vous envoyer le mail de confirmation')
end end
end end
end end

View file

@ -191,7 +191,15 @@ describe 'Prefilling a dossier (with a GET request):', js: true do
page.find('.fr-connect').click page.find('.fr-connect').click
click_on "Poursuivre mon dossier prérempli" expect(page).to have_content("Choisissez votre email de contact pour finaliser votre connexion")
expect(page).to have_selector("#use_france_connect_email_no", visible: false, wait: 10)
page.execute_script('document.getElementById("use_france_connect_email_no").click()')
fill_in("email", with: "exemple@email.com")
page.find("input[type='submit'][name='commit'][value='Confirmer']").click
expect(page).to have_content("Confirmez votre email")
click_on 'Continuer'
expect(page).to have_content('Vous avez un dossier prérempli')
find('.fr-btn.fr-mb-2w', text: 'Poursuivre mon dossier prérempli', wait: 10).click
end end
end end
end end

View file

@ -142,9 +142,15 @@ describe 'Prefilling a dossier (with a POST request):', js: true do
allow(FranceConnectService).to receive(:retrieve_user_informations_particulier).and_return(build(:france_connect_information)) allow(FranceConnectService).to receive(:retrieve_user_informations_particulier).and_return(build(:france_connect_information))
page.find('.fr-connect').click page.find('.fr-connect').click
expect(page).to have_content("Choisissez votre email de contact pour finaliser votre connexion")
expect(page).to have_selector("#use_france_connect_email_yes", visible: false, wait: 10)
page.execute_script('document.getElementById("use_france_connect_email_yes").click()')
click_on 'Confirmer'
expect(page).to have_content("Confirmez votre email")
click_on 'Continuer'
expect(page).to have_content('Vous avez un dossier prérempli') expect(page).to have_content('Vous avez un dossier prérempli')
click_on 'Poursuivre mon dossier prérempli' find('.fr-btn.fr-mb-2w', text: 'Poursuivre mon dossier prérempli', wait: 10).click
end end
end end
end end