lesli_shield 1.1.5 → 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 (59) 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 -770
  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/lesli_shield/users_controller.rb +3 -7
  16. data/app/controllers/users/confirmations_controller.rb +162 -49
  17. data/app/controllers/users/passwords_controller.rb +211 -28
  18. data/app/controllers/users/registrations_controller.rb +108 -83
  19. data/app/controllers/users/sessions_controller.rb +122 -84
  20. data/app/interfaces/lesli_shield/authentication_interface.rb +3 -3
  21. data/app/interfaces/lesli_shield/authorization_interface.rb +2 -2
  22. data/app/mailers/lesli_shield/devise_mailer.rb +73 -17
  23. data/app/models/concerns/lesli_shield/user_authentication.rb +54 -0
  24. data/app/models/lesli_shield/account.rb +4 -1
  25. data/app/models/lesli_shield/invite.rb +1 -1
  26. data/app/models/lesli_shield/user/role.rb +1 -1
  27. data/app/models/lesli_shield/user.rb +5 -2
  28. data/app/services/lesli_shield/profile/password_service.rb +47 -0
  29. data/app/services/lesli_shield/profile_service.rb +24 -0
  30. data/app/services/lesli_shield/user_registration_service.rb +41 -74
  31. data/app/services/lesli_shield/user_session_service.rb +1 -1
  32. data/app/services/lesli_shield/user_validator_service.rb +35 -39
  33. data/app/views/devise/confirmations/new.html.erb +58 -12
  34. data/app/views/devise/confirmations/show.html.erb +27 -60
  35. data/app/views/devise/passwords/edit.html.erb +59 -25
  36. data/app/views/devise/passwords/new.html.erb +61 -26
  37. data/app/views/devise/registrations/new.html.erb +80 -53
  38. data/app/views/devise/sessions/new.html.erb +53 -36
  39. data/app/views/devise/shared/_application-devise-simple.erb +18 -19
  40. data/app/views/devise/shared/_demo.html.erb +8 -6
  41. data/app/views/devise/shared/_error_messages.html.erb +45 -10
  42. data/app/views/devise/shared/_links.html.erb +6 -13
  43. data/app/views/lesli_shield/invites/_form.html.erb +1 -1
  44. data/app/views/lesli_shield/invites/index.html.erb +3 -2
  45. data/app/views/lesli_shield/profiles/_password-form.html.erb +68 -0
  46. data/app/views/lesli_shield/profiles/show.html.erb +29 -0
  47. data/app/views/lesli_shield/users/_information-card.html.erb +35 -45
  48. data/app/views/lesli_shield/users/_information-form.html.erb +9 -1
  49. data/config/initializers/devise.rb +52 -328
  50. data/config/locales/translations.en.yml +65 -2
  51. data/config/locales/translations.es.yml +65 -2
  52. data/config/routes.rb +4 -0
  53. data/lib/lesli_shield/engine.rb +44 -4
  54. data/lib/lesli_shield/router.rb +52 -18
  55. data/lib/lesli_shield/version.rb +2 -2
  56. data/readme.md +6 -4
  57. metadata +30 -5
  58. data/app/assets/config/lesli_shield_manifest.js +0 -48
  59. data/app/assets/stylesheets/lesli_shield/application.tailwind.css +0 -0
@@ -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
@@ -39,13 +39,13 @@ module LesliShield
39
39
 
40
40
  # # check if user has an active session
41
41
  # if current_session.equal? nil or !current_session.active?
42
- # current_user.logs.create({ title: "system_session_logout", description: "session finished by the system"})
42
+ # current_user.log(operation: "system_session_logout", description: "session finished by the system")
43
43
  # sign_out current_user
44
44
  # redirect_to "#{engine_path}/logout" and return
45
45
  # end
46
46
 
47
47
  # if !current_session.expiration_at.blank? && current_session.expiration_at < Time.current
48
- # current_user.logs.create({ title: "system_session_logout", description: "session expired by the system"})
48
+ # current_user.log(operation: "system_session_logout", description: "session expired by the system")
49
49
  # sign_out current_user
50
50
  # redirect_to "#{engine_path}/logout" and return
51
51
  # end
@@ -53,7 +53,7 @@ module LesliShield
53
53
  # # check password expiration date
54
54
  # if current_user.has_expired_password?
55
55
  # unless controller_name == "profiles"
56
- # current_user.logs.create({ description: "redirect_due_to_expired_password" })
56
+ # current_user.log(operation: "redirect_due_to_expired_password")
57
57
  # redirect_to "/administration/profile#force-password-reset", notice: I18n.t("core.users/sessions.messages_danger_password_expired")
58
58
  # return
59
59
  # 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
@@ -32,7 +32,10 @@ Building a better future, one line of code at a time.
32
32
 
33
33
  module LesliShield
34
34
  class Account < ApplicationRecord
35
- belongs_to :account, class_name: "Lesli::Account"
35
+ belongs_to :account,
36
+ class_name: "Lesli::Account",
37
+ inverse_of: :shield
38
+ validates :account_id, uniqueness: true
36
39
  has_many :dashboards
37
40
  has_many :invites
38
41
 
@@ -1,6 +1,6 @@
1
1
  module LesliShield
2
2
  class Invite < ApplicationRecord
3
- belongs_to :account
3
+ belongs_to :account, class_name: "LesliShield::Account"
4
4
  belongs_to :user, class_name: "Lesli::User"
5
5
 
6
6
  validates :email, presence: true, on: :create
@@ -1,7 +1,7 @@
1
1
  module LesliShield
2
2
  class User::Role < ApplicationRecord
3
3
  self.table_name = 'lesli_shield_user_roles'
4
- belongs_to :user
4
+ belongs_to :user, class_name: "Lesli::User"
5
5
  belongs_to :role, class_name: "Lesli::Role"
6
6
  has_many :roles
7
7
  end
@@ -1,5 +1,8 @@
1
1
  module LesliShield
2
- class User < ::Lesli::User
3
- #has_many :activities, class_name: "Lesli::Item::Activity"
2
+ # Namespace for Shield-owned records associated with Lesli::User, such as
3
+ # User::Role and User::Session. The authenticatable model remains
4
+ # Lesli::User; defining a second ActiveRecord subclass for the same table
5
+ # causes association type mismatches and can confuse Devise mappings.
6
+ module User
4
7
  end
5
8
  end
@@ -0,0 +1,47 @@
1
+ module LesliShield
2
+ module Profile
3
+ class PasswordService < Lesli::ApplicationLesliService
4
+ def initialize(user)
5
+ super(user)
6
+ self.resource = user
7
+ end
8
+
9
+ def update(params, current_session_id: nil)
10
+ password_updated = false
11
+
12
+ resource.transaction do
13
+ password_updated = resource.update_with_password(params)
14
+
15
+ if password_updated
16
+ clear_password_expiration
17
+ revoke_other_sessions(current_session_id)
18
+ end
19
+ end
20
+
21
+ unless password_updated
22
+ resource.errors.full_messages.each { |message| error(message) }
23
+ end
24
+
25
+ self
26
+ end
27
+
28
+ private
29
+
30
+ def clear_password_expiration
31
+ return unless resource.has_attribute?(:password_expiration_at)
32
+ return if resource.password_expiration_at.nil?
33
+
34
+ resource.update_column(:password_expiration_at, nil)
35
+ end
36
+
37
+ def revoke_other_sessions(current_session_id)
38
+ return if current_session_id.blank?
39
+
40
+ current_session = resource.sessions.find_by(id: current_session_id)
41
+ return unless current_session
42
+
43
+ resource.sessions.where.not(id: current_session.id).destroy_all
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,24 @@
1
+ module LesliShield
2
+ class ProfileService < Lesli::ApplicationLesliService
3
+ def initialize(user)
4
+ super(user)
5
+ self.resource = user
6
+ end
7
+
8
+ def show
9
+ resource
10
+ end
11
+
12
+ def update(params)
13
+ resource.assign_attributes(params)
14
+ resource.alias = resource.full_name_initials if resource.alias.blank?
15
+
16
+ unless resource.save
17
+ error(resource.errors.full_messages.to_sentence)
18
+ return self
19
+ end
20
+
21
+ self
22
+ end
23
+ end
24
+ end