lesli_shield 1.1.6 → 1.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +3 -0
  3. data/app/assets/stylesheets/lesli_shield/application.css +140 -1
  4. data/app/assets/stylesheets/lesli_shield/confirmations.css +917 -18
  5. data/app/assets/stylesheets/lesli_shield/confirmations.tailwind.css +918 -0
  6. data/app/assets/stylesheets/lesli_shield/passwords.css +872 -18
  7. data/app/assets/stylesheets/lesli_shield/passwords.tailwind.css +873 -0
  8. data/app/assets/stylesheets/lesli_shield/registrations.css +904 -832
  9. data/app/assets/stylesheets/lesli_shield/registrations.tailwind.css +908 -0
  10. data/app/assets/stylesheets/lesli_shield/sessions.css +984 -32
  11. data/app/assets/stylesheets/lesli_shield/sessions.tailwind.css +983 -1
  12. data/app/controllers/lesli_shield/profile/passwords_controller.rb +119 -0
  13. data/app/controllers/lesli_shield/profiles_controller.rb +63 -0
  14. data/app/controllers/lesli_shield/sessions_controller.rb +1 -2
  15. data/app/controllers/users/confirmations_controller.rb +162 -49
  16. data/app/controllers/users/passwords_controller.rb +211 -28
  17. data/app/controllers/users/registrations_controller.rb +108 -83
  18. data/app/controllers/users/sessions_controller.rb +122 -84
  19. data/app/interfaces/lesli_shield/authorization_interface.rb +2 -2
  20. data/app/mailers/lesli_shield/devise_mailer.rb +73 -17
  21. data/app/models/concerns/lesli_shield/user_authentication.rb +54 -0
  22. data/app/models/lesli_shield/account.rb +4 -1
  23. data/app/models/lesli_shield/invite.rb +1 -1
  24. data/app/models/lesli_shield/user/role.rb +1 -1
  25. data/app/models/lesli_shield/user.rb +5 -2
  26. data/app/services/lesli_shield/profile/password_service.rb +47 -0
  27. data/app/services/lesli_shield/profile_service.rb +24 -0
  28. data/app/services/lesli_shield/user_registration_service.rb +41 -74
  29. data/app/services/lesli_shield/user_session_service.rb +1 -1
  30. data/app/services/lesli_shield/user_validator_service.rb +30 -32
  31. data/app/views/devise/confirmations/new.html.erb +58 -12
  32. data/app/views/devise/confirmations/show.html.erb +27 -60
  33. data/app/views/devise/passwords/edit.html.erb +59 -25
  34. data/app/views/devise/passwords/new.html.erb +61 -26
  35. data/app/views/devise/registrations/new.html.erb +80 -53
  36. data/app/views/devise/sessions/new.html.erb +53 -36
  37. data/app/views/devise/shared/_application-devise-simple.erb +18 -19
  38. data/app/views/devise/shared/_demo.html.erb +8 -6
  39. data/app/views/devise/shared/_error_messages.html.erb +45 -10
  40. data/app/views/devise/shared/_links.html.erb +6 -13
  41. data/app/views/lesli_shield/invites/_form.html.erb +1 -1
  42. data/app/views/lesli_shield/invites/index.html.erb +3 -2
  43. data/app/views/lesli_shield/profiles/_password-form.html.erb +68 -0
  44. data/app/views/lesli_shield/profiles/show.html.erb +29 -0
  45. data/app/views/lesli_shield/users/_information-form.html.erb +9 -1
  46. data/config/initializers/devise.rb +52 -328
  47. data/config/locales/translations.en.yml +65 -2
  48. data/config/locales/translations.es.yml +65 -2
  49. data/config/routes.rb +4 -0
  50. data/lib/lesli_shield/engine.rb +38 -4
  51. data/lib/lesli_shield/router.rb +52 -18
  52. data/lib/lesli_shield/version.rb +2 -2
  53. data/readme.md +4 -2
  54. metadata +30 -5
  55. data/app/assets/config/lesli_shield_manifest.js +0 -48
  56. data/app/assets/stylesheets/lesli_shield/application.tailwind.css +0 -2
@@ -4,7 +4,7 @@
4
4
 
5
5
  Lesli
6
6
 
7
- Copyright (c) 2025, Lesli Technologies, S. A.
7
+ Copyright (c) 2026, Lesli Technologies, S. A.
8
8
 
9
9
  This program is free software: you can redistribute it and/or modify
10
10
  it under the terms of the GNU General Public License as published by
@@ -33,98 +33,123 @@ Building a better future, one line of code at a time.
33
33
  =end
34
34
 
35
35
  class Users::RegistrationsController < Devise::RegistrationsController
36
- before_action :configure_sign_up_params, only: [:create]
37
- # before_action :configure_account_update_params, only: [:update]
36
+ REGISTRATION_RATE_LIMIT_WINDOW = 1.hour
37
+
38
+ before_action :ensure_registration_allowed!, only: %i[new create]
39
+
40
+ rate_limit(
41
+ to: 10,
42
+ within: REGISTRATION_RATE_LIMIT_WINDOW,
43
+ by: -> { request.remote_ip },
44
+ with: :respond_to_registration_rate_limit,
45
+ only: :create,
46
+ name: "registration-ip"
47
+ )
48
+
49
+ rate_limit(
50
+ to: 3,
51
+ within: REGISTRATION_RATE_LIMIT_WINDOW,
52
+ by: :registration_email_rate_limit_key,
53
+ with: :respond_to_registration_rate_limit,
54
+ only: :create,
55
+ name: "registration-email"
56
+ )
57
+
58
+ # Devise owns user construction, validation, persistence, confirmation
59
+ # delivery, password cleanup, and the active/inactive response flow.
60
+ def create
61
+ super do |user|
62
+ record_user_creation_safely(user)
63
+ end
64
+ end
38
65
 
39
- # GET /resource/sign_up
40
- # def new
41
- # super
42
- # end
66
+ protected
43
67
 
44
- def create
45
- begin
68
+ # Standalone registrations remain inactive until confirmation creates the
69
+ # account and role. Preserve the existing UX by returning to the form with
70
+ # Devise's success notice instructing the user to check their email.
71
+ def after_inactive_sign_up_path_for(_user)
72
+ new_user_registration_path
73
+ end
46
74
 
47
- # Check if instance allow multi-account
48
- if !Lesli.config.security.dig(:allow_registration)
49
- raise(I18n.t("core.users/registrations.messages_error_registration_not_allowed"))
50
- end
75
+ private
51
76
 
52
- # build new user
53
- user = build_resource(sign_up_params)
77
+ def ensure_registration_allowed!
78
+ return if Lesli.config.security.dig(:allow_registration)
54
79
 
55
- # persist new user
56
- if user.save
57
- user.log(engine: LesliShield, source: self.class.name, action: action_name, operation: 'user_creation', description: 'User creation successfully')
58
- success("Account created, check your email")
59
- else
60
- raise(user.errors.full_messages.to_sentence)
61
- end
62
- rescue => exception
63
- #Honeybadger.notify(exception)
64
- danger(exception.message)
65
- end
66
- redirect_to(new_user_registration_path)
80
+ redirect_to(
81
+ new_user_session_path,
82
+ alert: I18n.t(
83
+ "lesli_shield.devise/registrations.message_registration_not_allowed"
84
+ ),
85
+ status: request.post? ? :see_other : :found
86
+ )
67
87
  end
68
88
 
69
- # GET /resource/edit
70
- # def edit
71
- # super
72
- # end
73
-
74
- # PUT /resource
75
- # def update
76
- # super
77
- # end
78
-
79
- # DELETE /resource
80
- # def destroy
81
- # super
82
- # end
83
-
84
- # GET /resource/cancel
85
- # Forces the session data which is usually expired after sign
86
- # in to be expired now. This is useful if the user wants to
87
- # cancel oauth signing in/up in the middle of the process,
88
- # removing all OAuth session data.
89
- # def cancel
90
- # super
91
- # end
89
+ def registration_email_rate_limit_key
90
+ normalized_email = registration_params[:email].to_s.strip.downcase
91
+ identity = normalized_email.presence || "blank:#{request.remote_ip}"
92
92
 
93
- protected
93
+ OpenSSL::HMAC.hexdigest(
94
+ "SHA256",
95
+ Rails.application.secret_key_base,
96
+ identity
97
+ )
98
+ end
99
+
100
+ def registration_params
101
+ params
102
+ .fetch(resource_name, ActionController::Parameters.new)
103
+ .permit(:email)
104
+ end
105
+
106
+ def respond_to_registration_rate_limit
107
+ message = I18n.t(
108
+ "lesli_shield.devise/registrations.message_too_many_attempts"
109
+ )
110
+
111
+ response.set_header(
112
+ "Retry-After",
113
+ REGISTRATION_RATE_LIMIT_WINDOW.to_i.to_s
114
+ )
115
+
116
+ respond_to do |format|
117
+ format.html { render_registration_rate_limit(message) }
118
+ format.json do
119
+ render json: { error: message }, status: :too_many_requests
120
+ end
121
+ format.any { head :too_many_requests }
122
+ end
123
+ end
94
124
 
95
- # If you have extra params to permit, append them to the sanitizer.
96
- # def configure_account_update_params
97
- # devise_parameter_sanitizer.permit(:account_update, keys: [:attribute])
98
- # end
99
-
100
- # If you have extra params to permit, append them to the sanitizer.
101
- def configure_sign_up_params
102
- devise_parameter_sanitizer.permit(:sign_up, keys: [
103
- :email,
104
- :password,
105
- :password_confirmation
106
- ])
125
+ def render_registration_rate_limit(message)
126
+ flash.now[:alert] = message
127
+ self.resource = resource_class.new(email: registration_params[:email])
128
+ set_minimum_password_length
129
+ render :new, status: :too_many_requests
107
130
  end
108
131
 
109
- # The path used after sign up.
110
- # def after_sign_up_path_for(resource)
111
- # super(resource)
112
- # end
113
-
114
- # The path used after sign up for inactive accounts.
115
- # def after_inactive_sign_up_path_for(resource)
116
- # super(resource)
117
- # end
118
-
119
- # def sign_up_params
120
- # params.permit(:sign_up, keys: [
121
- # :email,
122
- # :password,
123
- # :password_confirmation
124
- # ])
125
- # end
126
-
127
- def sign_up_params
128
- params.fetch(:user, {}).permit(:email, :password, :password_confirmation)
132
+ # A standalone signup has no tenant yet, so its durable audit event belongs
133
+ # to the later confirmation transaction. Keep support for pre-associated
134
+ # resources without allowing an optional audit failure to undo registration.
135
+ def record_user_creation_safely(user)
136
+ return unless user.persisted? && user.account.present?
137
+
138
+ user.log(
139
+ engine: LesliShield,
140
+ source: self.class.name,
141
+ action: action_name,
142
+ operation: "user_creation_succeeded",
143
+ description: "User created successfully"
144
+ )
145
+ rescue StandardError => exception
146
+ Rails.error.report(
147
+ exception,
148
+ handled: true,
149
+ context: {
150
+ controller: self.class.name,
151
+ operation: "user_creation_succeeded"
152
+ }
153
+ )
129
154
  end
130
155
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  Lesli
4
4
 
5
- Copyright (c) 2025, Lesli Technologies, S. A.
5
+ Copyright (c) 2026, Lesli Technologies, S. A.
6
6
 
7
7
  This program is free software: you can redistribute it and/or modify
8
8
  it under the terms of the GNU General Public License as published by
@@ -30,109 +30,147 @@ Building a better future, one line of code at a time.
30
30
  // ·
31
31
  =end
32
32
 
33
- class Users::SessionsController < Devise::SessionsController
33
+ require "openssl"
34
34
 
35
- # Creates a new session for the user and allows them access to the platform.
36
- #
37
- # Devise provides extension points such as Warden hooks and a custom FailureApp
38
- # to modify the authentication flow. However, in this case we need full and
39
- # explicit control over each step of the login process, including validation,
40
- # logging, session creation, and redirection.
41
- #
42
- # For that reason, the default Devise session logic is intentionally overridden
43
- # and reimplemented here. While this approach is less conventional and may
44
- # introduce compatibility risks with future Devise releases, it provides a
45
- # predictable and fully controlled authentication pipeline, which is important
46
- # for the needs of this framework.
47
- #
48
- # This trade-off is accepted as part of the framework’s lifecycle: any
49
- # incompatibilities introduced by Devise updates will be addressed and
50
- # maintained as needed.
35
+ class Users::SessionsController < Devise::SessionsController
36
+ LOGIN_RATE_LIMIT_WINDOW = 15.minutes
37
+
38
+ rate_limit(
39
+ to: 20,
40
+ within: LOGIN_RATE_LIMIT_WINDOW,
41
+ by: -> { request.remote_ip },
42
+ with: :respond_to_login_rate_limit,
43
+ only: :create,
44
+ name: "login-ip"
45
+ )
46
+
47
+ rate_limit(
48
+ to: 10,
49
+ within: LOGIN_RATE_LIMIT_WINDOW,
50
+ by: :login_email_rate_limit_key,
51
+ with: :respond_to_login_rate_limit,
52
+ only: :create,
53
+ name: "login-email"
54
+ )
55
+
56
+ # Devise owns the authentication pipeline and yields only after credentials
57
+ # and active_for_authentication? have been accepted and the web session has
58
+ # been established. LesliShield adds its application session and audit event
59
+ # without reimplementing Devise's create action.
51
60
  def create
61
+ super do |user|
62
+ persist_application_session!(user)
63
+ record_successful_login_analytics
64
+ end
65
+ end
52
66
 
53
- # Use guarden to check if the users credetials are valid
54
- self.resource = warden.authenticate(auth_options)
67
+ protected
55
68
 
56
- # respond with a no valid credentials generic error if warden
57
- # cannot validate the user
58
- unless resource
59
- danger(I18n.t("lesli_shield.devise/sessions.message_not_valid_credentials"))
60
- redirect_to user_session_path(r: sign_in_params[:redirect]) and return
61
- end
69
+ # Devise calls this method only after a successful authentication. url_from
70
+ # accepts local paths and URLs for the current host while rejecting external
71
+ # and protocol-relative URLs.
72
+ def after_sign_in_path_for(_user)
73
+ url_from(requested_redirect) || configured_after_sign_in_path
74
+ end
62
75
 
63
- user = resource
76
+ private
77
+
78
+ # Devise has already established the web session when its create action
79
+ # yields. If either mandatory write fails, remove that session so the request
80
+ # cannot leave a partially completed login behind.
81
+ def persist_application_session!(user)
82
+ application_session_id = create_application_session_and_audit!(user)
83
+ session[:user_session_id] = application_session_id
84
+ rescue StandardError
85
+ rollback_web_session
86
+ raise
87
+ end
64
88
 
65
- # check if user has a valid account
66
- unless user.account
67
- danger(I18n.t("lesli_shield.devise/sessions.message_not_confirmed_account"))
68
- redirect_to user_session_path(r: sign_in_params[:redirect]) and return
89
+ # The LesliShield session and its single successful-login audit event are
90
+ # committed atomically.
91
+ def create_application_session_and_audit!(user)
92
+ Lesli::User.transaction do
93
+ application_session = LesliShield::UserSessionService
94
+ .new(user)
95
+ .create(request.remote_ip, detected_user_agent)
96
+ .result
97
+
98
+ user.log(
99
+ engine: LesliShield,
100
+ source: self.class.name,
101
+ action: action_name,
102
+ operation: "session_creation_succeeded",
103
+ description: "Session creation successful",
104
+ session_id: application_session.id
105
+ )
106
+
107
+ application_session.id
69
108
  end
109
+ end
70
110
 
71
- log = nil
111
+ def rollback_web_session
112
+ session.delete(:user_session_id)
113
+ sign_out(resource_name)
114
+ end
72
115
 
73
- # Save a log for the current login attempt
74
- log = user.log(
75
- engine: LesliShield,
76
- source: self.class.name,
77
- action: action_name,
78
- operation: 'session_new',
79
- description: 'Session creation attempt'
80
- ) if defined?(LesliAudit)
116
+ # Request and device records are analytics, not additional authentication
117
+ # audit events.
118
+ def record_successful_login_analytics
119
+ log_requests if respond_to?(:log_requests)
120
+ log_devices if respond_to?(:log_devices)
121
+ end
81
122
 
82
- # check if user meet requirements to create a new session
83
- LesliShield::UserValidatorService.new(user).valid? do |valid, failures|
123
+ def detected_user_agent
124
+ get_user_agent if respond_to?(:get_user_agent)
125
+ end
84
126
 
85
- # if user do not meet requirements to login
86
- unless valid
127
+ def configured_after_sign_in_path
128
+ Lesli.config.shield.dig(:path_after_login) || root_path
129
+ end
87
130
 
88
- failures_string = failures.join(", ")
89
- danger(failures_string)
90
- log.update(description: failures_string) if log
91
- redirect_to user_session_path(r: sign_in_params[:redirect]) and return
92
- end
93
- end
131
+ def requested_redirect
132
+ login_request_params[:redirect]
133
+ end
94
134
 
95
- # create a new session for the user
96
- current_session = LesliShield::UserSessionService.new(user)
97
- .create(request.remote_ip, (get_user_agent(false) if log))
98
- .result
135
+ def login_request_params
136
+ params.fetch(:user, {}).permit(
137
+ :email,
138
+ :password,
139
+ :remember_me,
140
+ :redirect
141
+ )
142
+ end
99
143
 
100
- # make session id globally available
101
- session[:user_session_id] = current_session[:id]
144
+ def login_email_rate_limit_key
145
+ normalized_email = login_request_params[:email].to_s.strip.downcase
146
+ identity = normalized_email.presence || "blank:#{request.remote_ip}"
102
147
 
103
- # do a user login
104
- sign_in(resource_name, user)
148
+ OpenSSL::HMAC.hexdigest(
149
+ "SHA256",
150
+ Rails.application.secret_key_base,
151
+ identity
152
+ )
153
+ end
105
154
 
106
- # update logs with a successful login
107
- log.update(
108
- description: "Session creation successful",
109
- session_id: current_session[:id]
110
- ) if log
155
+ def respond_to_login_rate_limit
156
+ message = I18n.t("lesli_shield.devise/sessions.message_too_many_attempts")
157
+ response.set_header("Retry-After", LOGIN_RATE_LIMIT_WINDOW.to_i.to_s)
111
158
 
112
- # respond successful and send the path user should go
113
- # respond_with_successful({ default_path: user.has_role_with_default_path?() })
114
- # respond_with_successful({ default_path: Lesli.config.path_after_login || "/" })
115
- redirect_to safe_redirect_path(sign_in_params[:redirect])
159
+ respond_to do |format|
160
+ format.html do
161
+ self.resource = resource_class.new(
162
+ email: login_request_params[:email]
163
+ )
116
164
 
117
- # Save the user_agent for every new session
118
- log_devices if log
119
- end
165
+ flash.now[:alert] = message
166
+ render :new, status: :too_many_requests
167
+ end
120
168
 
121
- private
169
+ format.json do
170
+ render json: { error: message }, status: :too_many_requests
171
+ end
122
172
 
123
- def safe_redirect_path redirect_path
124
- if redirect_path.present?
125
- uri = URI.parse(redirect_path)
126
- # Ensures it's a local path
127
- return redirect_path if uri.relative?
173
+ format.any { head :too_many_requests }
128
174
  end
129
- Lesli.config.shield.dig(:path_after_login) || root_path
130
- rescue URI::InvalidURIError
131
- # If the URL is invalid, fallback to root
132
- root_path
133
- end
134
-
135
- def sign_in_params
136
- params.fetch(:user, {}).permit(:email, :password, :redirect)
137
175
  end
138
176
  end
@@ -31,7 +31,7 @@ module LesliShield
31
31
  :operation => :authorize_request,
32
32
  :description => "Privilege not found for: #{request.path}"
33
33
  )
34
- return respond_with_unauthorized({ controller: params[:controller], action: params[:action] })
34
+ return respond_with_forbidden({ controller: params[:controller], action: params[:action] })
35
35
  end
36
36
 
37
37
  unless granted
@@ -39,7 +39,7 @@ module LesliShield
39
39
  :operation => :authorize_request,
40
40
  :description => "Privilege not granted for: #{request.path}"
41
41
  )
42
- return respond_with_unauthorized({ controller: params[:controller], action: params[:action] })
42
+ return respond_with_forbidden({ controller: params[:controller], action: params[:action] })
43
43
  end
44
44
  end
45
45
  end
@@ -32,34 +32,41 @@ Building a better future, one line of code at a time.
32
32
  module LesliShield
33
33
  class DeviseMailer < Lesli::ApplicationLesliMailer
34
34
 
35
+ default from: -> { configured_sender }
36
+
35
37
  # Sends an email with instructions to allow the user reset the password
36
38
  def reset_password_instructions(user, token, opts = {})
37
39
 
38
40
  # defaults for new accounts/users
39
41
  email_template = "devise/reset_password_instructions"
40
- email_subject = I18n.t("core.users/confirmations.mailer_email_verification")
42
+ email_subject = I18n.t(
43
+ "lesli_shield.devise/passwords.mailer_subject"
44
+ )
41
45
 
42
46
  # email parameters
43
47
  params = {
44
- url: build_url("/password/edit", { reset_password_token: token}),
48
+ url: password_reset_url(token),
45
49
  full_name: user.full_name
46
50
  }
47
51
 
48
52
  # send email
49
- email(
50
- params,
53
+ send_email(
54
+ params: params,
55
+ options: opts,
51
56
  to: user.email,
52
57
  subject: email_subject,
53
58
  template_name: email_template
54
59
  )
55
- end
60
+ end
56
61
 
57
62
  # Sends an email to allow the user confirm the email address
58
63
  def confirmation_instructions(user, token, opts = {})
59
64
 
60
65
  # defaults for new accounts/users
61
66
  email_template = "devise/confirmation_instructions"
62
- email_subject = I18n.t("core.users/confirmations.mailer_email_verification")
67
+ email_subject = I18n.t(
68
+ "lesli_shield.devise/confirmations.mailer_confirmation_subject"
69
+ )
63
70
 
64
71
  # # custom email and subject if user is changin his email address
65
72
  # if !record.unconfirmed_email.blank?
@@ -69,30 +76,79 @@ module LesliShield
69
76
 
70
77
  # Depending on wheter there is a new user or they are changing their email,
71
78
  # one or another field will be used
72
- email_recipient = user.unconfirmed_email || user.email
79
+ email_recipient = user.unconfirmed_email.presence || user.email
73
80
 
74
81
  # email parameters
75
82
  params = {
76
- url: build_url("/confirmation", { confirmation_token: token})
83
+ url: account_confirmation_url(token)
77
84
  }
78
85
 
79
86
  # send email
80
- email(
81
- params,
87
+ send_email(
88
+ params: params,
89
+ options: opts,
82
90
  to: email_recipient,
83
91
  subject: email_subject,
84
92
  template_name: email_template
85
93
  )
86
94
  end
87
95
 
88
- def welcome(user)
89
- email_recipient = user.unconfirmed_email || user.email
90
- email(
91
- { :user => user },
92
- to: email_recipient,
93
- subject: "test",
94
- template_name: "devise/welcome"
96
+ def welcome(user, opts = {})
97
+ email_recipient = user.unconfirmed_email.presence || user.email
98
+ send_email(
99
+ params: { user: user },
100
+ options: opts,
101
+ to: email_recipient,
102
+ subject: I18n.t(
103
+ "lesli_shield.devise/confirmations.mailer_welcome_subject"
104
+ ),
105
+ template_name: "lesli/welcome"
106
+ )
107
+ end
108
+
109
+ private
110
+
111
+ def send_email(params:, options:, to:, subject:, template_name:)
112
+ headers = { to: to, subject: subject }.merge(options)
113
+ headers.delete(:template_name)
114
+
115
+ @params = @params.merge(params)
116
+ build_app_from_params(params)
117
+
118
+ mail(
119
+ **headers,
120
+ template_name: template_name
95
121
  )
96
122
  end
123
+
124
+ def configured_sender
125
+ sender = Devise.mailer_sender
126
+ sender = sender.call(:user) if sender.respond_to?(:call)
127
+
128
+ sender.presence ||
129
+ Rails.application.config.action_mailer.default_options&.dig(:from).presence ||
130
+ "no-reply@lesli.dev"
131
+ end
132
+
133
+ # Use the host application's named route so password recovery continues
134
+ # to work when authentication is mounted under a custom path.
135
+ def password_reset_url(token)
136
+ Rails.application.routes.url_helpers.edit_user_password_url(
137
+ **action_mailer_url_options,
138
+ reset_password_token: token
139
+ )
140
+ end
141
+
142
+ def account_confirmation_url(token)
143
+ Rails.application.routes.url_helpers.user_confirmation_url(
144
+ **action_mailer_url_options,
145
+ confirmation_token: token
146
+ )
147
+ end
148
+
149
+ def action_mailer_url_options
150
+ options = Rails.application.config.action_mailer.default_url_options
151
+ (options || {}).to_h.symbolize_keys
152
+ end
97
153
  end
98
154
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LesliShield
4
+ # Installs Shield's Devise-based authentication implementation on the core
5
+ # user model. Lesli itself only consumes current_user and remains free of a
6
+ # direct dependency on this engine or on Devise.
7
+ module UserAuthentication
8
+ extend ActiveSupport::Concern
9
+
10
+ # This module is prepended after Devise installs its model modules so
11
+ # LesliShield can extend, rather than replace, Devise's eligibility
12
+ # checks. Calling super preserves confirmable, lockable, and any other
13
+ # authentication provider rules.
14
+ module LoginEligibility
15
+ def active_for_authentication?
16
+ super && lesli_shield_login_validation.valid?
17
+ end
18
+
19
+ def inactive_message
20
+ return super if lesli_shield_login_validation.valid?
21
+
22
+ :invalid
23
+ end
24
+
25
+ private
26
+
27
+ def lesli_shield_login_validation
28
+ return @lesli_shield_login_validation if defined?(
29
+ @lesli_shield_login_validation
30
+ )
31
+
32
+ @lesli_shield_login_validation = LesliShield::UserValidatorService
33
+ .new(self)
34
+ .validate
35
+ end
36
+ end
37
+
38
+ DEVISE_MODULES = %i[
39
+ database_authenticatable
40
+ registerable
41
+ rememberable
42
+ recoverable
43
+ validatable
44
+ confirmable
45
+ trackable
46
+ lockable
47
+ ].freeze
48
+
49
+ included do
50
+ devise(*LesliShield::UserAuthentication::DEVISE_MODULES)
51
+ prepend LesliShield::UserAuthentication::LoginEligibility
52
+ end
53
+ end
54
+ end