Class: Account

Inherits:
ApplicationRecord show all
Includes:
Memery, Models::Auditable, PgSearch::Model
Defined in:
app/models/account.rb

Overview

== Schema Information

Table name: accounts
Database name: primary

id :integer not null, primary key
authentication_mode :integer default(0), not null
confirmation_sent_at :datetime
confirmation_token :string(255)
confirmed_at :datetime
current_sign_in_at :datetime
current_sign_in_ip :string(255)
disabled :boolean default(FALSE), not null
email :citext
encrypted_password :string(255) default("")
failed_attempts :integer default(0)
ignore_ip_visit_check :boolean default(FALSE), not null
inherited_role_ids :integer default([]), is an Array
inherited_role_names :string default([]), is an Array
invitation_accepted_at :datetime
invitation_created_at :datetime
invitation_limit :integer
invitation_sent_at :datetime
invitation_token :string(255)
invited_by_type :string(255)
is_guest :boolean
last_login :datetime
last_sign_in_at :datetime
last_sign_in_ip :string(255)
locked_at :datetime
login :citext not null
name :string(100) default("")
password_salt :string(255) default("")
remember_created_at :datetime
require_myp_migration :boolean default(FALSE)
reset_password_sent_at :datetime
reset_password_token :string(255)
return_path_for_invite :string(255)
sign_in_count :integer default(0)
unlock_token :string(255)
created_at :datetime
updated_at :datetime
invited_by_id :integer
my_projects_user_id :integer
party_id :integer

Indexes

index_accounts_on_confirmation_token (confirmation_token) UNIQUE
index_accounts_on_email (email)
index_accounts_on_inherited_role_names (inherited_role_names) USING gin
index_accounts_on_invitation_token (invitation_token)
index_accounts_on_invited_by_id (invited_by_id)
index_accounts_on_login (login) UNIQUE
index_accounts_on_my_projects_user_id (my_projects_user_id)
index_accounts_on_party_id (party_id)
index_accounts_on_reset_password_token (reset_password_token) UNIQUE
index_accounts_on_unlock_token (unlock_token) UNIQUE

Foreign Keys

accounts_party_id_fkey (party_id => parties.id)

Defined Under Namespace

Classes: Inviter

Constant Summary collapse

AUTH_INTERNAL =

Auth internal.

0
AUTH_GOOGLE =

Auth google.

1
BCRYPT_HASH_PREFIX_RE =

Bcrypt-shape detector. Bcrypt outputs $2a$, $2b$, or $2y$
prefixes (variant + cost-factor + salt + hash). A non-bcrypt-shaped
encrypted_password is the legacy restful_authentication_sha1
hex digest.

/\A\$2[aby]\$/
LEGACY_SHA1_STRETCHES =

Number of SHA1 rounds the legacy restful_authentication_sha1
encryptor was configured with (Devise.stretches = 10 historically,
before we repurposed stretches to mean bcrypt cost). Pinned here
so the verify-time reconstruction in legacy_sha1_digest doesn't
drift if Devise.stretches is bumped.

10

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Constants included from Schedulable

Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Belongs to collapse

Methods included from Models::Auditable

#creator, #updater

Has many collapse

Has and belongs to many collapse

Delegated Instance Attributes collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Models::Auditable

#all_skipped_columns, #audit_reference_data, #should_not_save_version, #stamp_record

Methods inherited from ApplicationRecord

ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation

Methods included from Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Dynamic Method Handling

This class handles dynamic methods through the method_missing method

#method_missing(method_id, *arguments) ⇒ Object (protected)



597
598
599
600
601
602
603
604
605
606
607
# File 'app/models/account.rb', line 597

def method_missing(method_id, *arguments, &)
  if /^is_\w+?/.match?(method_id.to_s)
    self.class.send :define_method, method_id do
      role_to_check = method_id.to_s.match(/^is_(\w+)?/)[1]
      send(:has_role?, role_to_check)
    end
    send(method_id)
  else
    super
  end
end

Instance Attribute Details

#authentication_modeObject (readonly)



121
# File 'app/models/account.rb', line 121

validates :authentication_mode, presence: true, inclusion: { in: [AUTH_INTERNAL, AUTH_GOOGLE] }

#emailObject (readonly)



123
# File 'app/models/account.rb', line 123

validates :email, presence: true, email_format: true

#email_reset_instructionsObject

Returns the value of attribute email_reset_instructions.



136
137
138
# File 'app/models/account.rb', line 136

def email_reset_instructions
  @email_reset_instructions
end

#invitation_codeObject

Returns the value of attribute invitation_code.



136
137
138
# File 'app/models/account.rb', line 136

def invitation_code
  @invitation_code
end

#loginObject (readonly)



122
# File 'app/models/account.rb', line 122

validates :login, presence: true, uniqueness: true

#marketing_sign_upObject

Returns the value of attribute marketing_sign_up.



136
137
138
# File 'app/models/account.rb', line 136

def 
  @marketing_sign_up
end

#passwordObject (readonly)

from devise validatable, we only keep password validations, not email since they require uniqueness if present, which we do not

Validations:

  • Presence ({ if: :password_required? })
  • Confirmation ({ if: -> { password.present? } })
  • Length ({ within: Devise.password_length, allow_blank: true })


127
# File 'app/models/account.rb', line 127

validates :password, presence: { if: :password_required? }

#password_confirmationObject (readonly)



133
# File 'app/models/account.rb', line 133

validates :password_confirmation, presence: true, if: -> { password.present? }

#require_passwordObject

Returns the value of attribute require_password.



136
137
138
# File 'app/models/account.rb', line 136

def require_password
  @require_password
end

#skip_notificationObject

Returns the value of attribute skip_notification.



136
137
138
# File 'app/models/account.rb', line 136

def skip_notification
  @skip_notification
end

Class Method Details

.account_api_signed_in(api_authentication_token) ⇒ Object



155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'app/models/account.rb', line 155

def self.(api_authentication_token)
  # find account with any api_authentications using the api_authentication_token and return the account if present and not expired, otherwise, if expired, remove the api_authentication from the account
  res = nil
  api_auth = ApiAuthentication.find_by(api_authentication_token: api_authentication_token)
  if api_auth
    if api_auth.expired?
      api_auth.destroy
      res = nil
    else
      res = api_auth.
    end
  end
  res
end

.active_accountsActiveRecord::Relation<Account>

A relation of Accounts that are active accounts. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Account>)

See Also:



113
# File 'app/models/account.rb', line 113

scope :active_accounts, -> { where.not(disabled: true) }

.email_loginsActiveRecord::Relation<Account>

A relation of Accounts that are email logins. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Account>)

See Also:



114
# File 'app/models/account.rb', line 114

scope :email_logins, -> { where("accounts.login LIKE '%@%'") }

.get_unique_login_for_email(email) ⇒ Object



170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'app/models/account.rb', line 170

def self.(email)
   = email
  if Account.where(login: email).exists?
    name_part, = email.split('@')
     = name_part
    counter = 0
     = 
    while Account.where(login: ).exists?
      counter += 1
       = "#{}-#{counter}"
    end
  end
  
end

Instance Method Details

#abilityObject



278
279
280
# File 'app/models/account.rb', line 278

def ability
  @ability ||= Ability.new(party)
end

#account_created_notify_reps_and_master_accountObject



484
485
486
487
488
# File 'app/models/account.rb', line 484

def 
  return if party&.is_employee? || skip_notification

  notify(activity: 'created_account') if party.present?
end

#active_for_authentication?Boolean

Returns:

  • (Boolean)


189
190
191
192
# File 'app/models/account.rb', line 189

def active_for_authentication?
  Rails.logger.debug { "active_for_authentication? disabled?: #{disabled?}" }
  super && !disabled?
end

#after_database_authenticationObject

Devise calls this after a successful database authentication
(database_authenticatable strategy → resource.after_database_authentication).
We piggyback to drain the bcrypt-migration tail: if the password
we just verified came in via legacy SHA1 (state A) or bcrypt-wrap
(state B), valid_password? stashed the plaintext on
@needs_password_rehash; here we re-set the password through
Devise's bcrypt setter (writes pure-bcrypt to encrypted_password)
and clear the no-longer-needed password_salt. Saved without
validations because we don't want a model-level validation drift
to mask a successful auth and lock the user out.

Custom controllers that call valid_password? directly and then
sign_in(:account, …) (e.g. Auth::CustomerSessionsController#authenticate,
#finish_fast_checkout) bypass the Devise strategy and therefore
this callback — they should call consume_password_rehash!
explicitly after a successful sign-in.



519
520
521
522
# File 'app/models/account.rb', line 519

def after_database_authentication
  super if defined?(super)
  consume_password_rehash!
end

#api_authenticationsActiveRecord::Relation<ApiAuthentication>

Returns:

See Also:



109
# File 'app/models/account.rb', line 109

has_many :api_authentications, dependent: :destroy

#api_sign_in!(is_guest = false) ⇒ Object

Here we manage api_authentications which is used only for API authentication, we don't want it to mix or reset the usual web based authentication token which is used for employee 'login as this customer" login or "continue as guest" accounts for email link login and is reset on successful registration or social login authentication. We want API authentication - the only kind of api login mechanism via email/password, social login or continue as guest via a non web based app - to be independent



401
402
403
404
405
406
407
# File 'app/models/account.rb', line 401

def api_sign_in!(is_guest = false)
  # create an api_authentication and return the api_authentication_token
  api_auth = api_authentications.build
  api_auth.is_guest = is_guest
  save!
  api_auth.api_authentication_token
end

#api_sign_out!(api_authentication_token) ⇒ Object



409
410
411
412
413
# File 'app/models/account.rb', line 409

def api_sign_out!(api_authentication_token)
  # remove api_authentication matching the api_authentication_token
  api_auth = api_authentications.find_by(api_authentication_token: api_authentication_token)
  api_auth&.destroy
end

#apply_omniauth(omniauth) ⇒ Object

If you use a social media account login, then this extracts the name and stores it in the party
and builds the authentication



351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
# File 'app/models/account.rb', line 351

def apply_omniauth(omniauth)
  logger.debug "apply_omniauth(omniauth): party.id: #{party.id}"
  logger.debug "apply_omniauth(omniauth): email: #{email}"
  logger.debug "apply_omniauth(omniauth): omniauth: #{omniauth.inspect}"
  if .blank?
    self. = Authentication.extract_email_from_omniauth_hash(omniauth)
    self.email = Authentication.extract_email_from_omniauth_hash(omniauth)
    logger.debug "apply_omniauth(omniauth): email: #{email}"
    logger.debug "apply_omniauth(omniauth): party.email: #{begin
      party.email
    rescue StandardError
      'n/a'
    end}"
  end

  if name.blank? || name.downcase.index('guest').present?
    omniauth_name = Authentication.extract_name_from_omniauth_hash(omniauth)
    logger.debug "apply_omniauth(omniauth): omniauth_name: #{omniauth_name}"
    self.name = omniauth_name if omniauth_name
    logger.debug "apply_omniauth(omniauth): SET name: #{name}"
    logger.debug "apply_omniauth(omniauth): party.name: #{begin
      party.name
    rescue StandardError
      'n/a'
    end}"
  end
  authentications.build(provider: omniauth['provider'], uid: omniauth['uid'])
  @pending_social_login_picture_url = Authentication.extract_picture_url_from_omniauth_hash(omniauth)
end

#auth_token_required?Boolean

Returns:

  • (Boolean)


444
445
446
# File 'app/models/account.rb', line 444

def auth_token_required?
  .present? && authentications.empty? && encrypted_password.blank? && !is_employee?
end

#authentication_internal?Boolean

Returns:

  • (Boolean)


282
283
284
# File 'app/models/account.rb', line 282

def authentication_internal?
  authentication_mode == AUTH_INTERNAL
end

#authentication_methodsObject



389
390
391
392
393
394
395
396
397
# File 'app/models/account.rb', line 389

def authentication_methods
  auth_methods = []
  auth_methods << 'password' if encrypted_password.present?
  # auth_methods << "guest_email" if auth_token_required?
  authentications.each do |auth|
    auth_methods << auth.provider
  end
  auth_methods
end

#authentication_mode_nameObject



318
319
320
# File 'app/models/account.rb', line 318

def authentication_mode_name
  %w[Internal Google][authentication_mode]
end

#authenticationsActiveRecord::Relation<Authentication>

Returns:

See Also:



108
# File 'app/models/account.rb', line 108

has_many :authentications, dependent: :destroy

#bcrypt_shaped_password?Boolean

Returns:

  • (Boolean)


213
214
215
# File 'app/models/account.rb', line 213

def bcrypt_shaped_password?
  encrypted_password.to_s.match?(BCRYPT_HASH_PREFIX_RE)
end

#can?Object

Alias for Ability#can?

Returns:

  • (Object)

    Ability#can?

See Also:



151
# File 'app/models/account.rb', line 151

delegate :can?, :cannot?, to: :ability

#can_access_mcp?Boolean

MCP (Model Context Protocol) access methods
Used for AI assistant integrations like Cursor/Claude

Returns:

  • (Boolean)


418
419
420
# File 'app/models/account.rb', line 418

def can_access_mcp?
  is_employee? && has_role?('mcp_access')
end

#can_impersonate?(customer_account) ⇒ Boolean

Returns:

  • (Boolean)


304
305
306
307
308
309
# File 'app/models/account.rb', line 304

def can_impersonate?()
  is_manager? ||
    is_customer_service_rep? ||
    party_id == .customer.primary_sales_rep_id ||
    party_id == .customer.secondary_sales_rep_id
end

#cannot?Object

Alias for Ability#cannot?

Returns:

  • (Object)

    Ability#cannot?

See Also:



151
# File 'app/models/account.rb', line 151

delegate :can?, :cannot?, to: :ability

#check_for_reset_instructionsObject (protected)



575
576
577
578
579
580
# File 'app/models/account.rb', line 575

def check_for_reset_instructions
  return unless email_reset_instructions == '1'

  self.email_password_reset_instructions = nil
  send_reset_password_instructions
end

#confirmation_required?Boolean (protected)

Returns:

  • (Boolean)


556
557
558
559
560
561
# File 'app/models/account.rb', line 556

def confirmation_required?
  return false if party&.is_employee?
  return false if authentications.present?

  true
end

#consume_password_rehash!Boolean

Idempotent rehash of a transitional-state password to pure bcrypt.
No-op when nothing was queued by valid_password? (e.g. the user
was already on pure bcrypt, or this method got called twice).

Returns:

  • (Boolean)

    true when a rehash was performed; false otherwise.



528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
# File 'app/models/account.rb', line 528

def consume_password_rehash!
  plain = @needs_password_rehash
  @needs_password_rehash = nil
  return false if plain.blank?

  self.password = plain                  # Devise setter writes bcrypt to encrypted_password
  self.password_salt = nil               # legacy SHA1 salt no longer needed for pure bcrypt
  # Transparent migration step — the user's password did not actually change,
  # so suppress the after_commit `notify_credentials_changed` mailer that
  # would otherwise see `saved_change_to_encrypted_password?` and send a
  # "Your password was changed" email on every legacy-state login. Capture
  # and restore the prior value so a caller that already had
  # `skip_notification = true` for a broader flow doesn't get clobbered;
  # `ensure` so a raising callback inside the save can't leave the flag
  # stuck on the instance.
  prior_skip_notification = skip_notification
  self.skip_notification = true
  begin
    saved = save(validate: false)
  ensure
    self.skip_notification = prior_skip_notification
  end
  Appsignal.increment_counter('password_rehashed_to_pure_bcrypt', 1) if saved && defined?(Appsignal)
  saved
end

#customerObject

Alias for Party#customer

Returns:

  • (Object)

    Party#customer

See Also:



286
# File 'app/models/account.rb', line 286

delegate :customer, to: :party

#employeeEmployee

Returns:

See Also:



105
# File 'app/models/account.rb', line 105

belongs_to :employee, class_name: 'Employee', foreign_key: :party_id, optional: true

#fetch_inherited_role_namesObject



495
496
497
# File 'app/models/account.rb', line 495

def fetch_inherited_role_names
  Role.where(id: inherited_role_ids).order(:name).pluck(:name)
end

#fully_enabled?Boolean

Returns:

  • (Boolean)


322
323
324
# File 'app/models/account.rb', line 322

def fully_enabled?
  .present? && (encrypted_password.present? || authentications.present?)
end

#generate_mcp_token!Object



422
423
424
425
426
# File 'app/models/account.rb', line 422

def generate_mcp_token!
  raise 'Account does not have MCP access' unless can_access_mcp?

  api_sign_in!
end

#has_role?(*roles_in_question, admin_check: true) ⇒ Boolean

Returns:

  • (Boolean)


311
312
313
314
315
316
# File 'app/models/account.rb', line 311

def has_role?(*roles_in_question, admin_check: true)
  # Handle both array and individual arguments
  roles_array = roles_in_question.flatten.map(&:to_s).map(&:downcase)
  (admin_check && inherited_role_names.include?('admin')) ||
    inherited_role_names.map(&:downcase).intersect?(roles_array)
end

#headers_for(_action) ⇒ Object



331
332
333
334
335
336
337
# File 'app/models/account.rb', line 331

def headers_for(_action)
  if party.respond_to?(:primary_sales_rep?)
    { from: "'#{party.primary_sales_rep.name}' <#{party.primary_sales_rep.email}>" }
  else
    {}
  end
end

#inactive_messageObject



194
195
196
197
198
# File 'app/models/account.rb', line 194

def inactive_message
  return super unless disabled?

  I18n.t('devise.failure.account_disabled', phone: CompanyConstants::PHONE[:usa])
end

#invitationsActiveRecord::Relation<Invitation>

Returns:

  • (ActiveRecord::Relation<Invitation>)

See Also:



107
# File 'app/models/account.rb', line 107

has_many :invitations, class_name: self.class.to_s, as: :invited_by

#is_admin?Boolean

Returns:

  • (Boolean)


296
297
298
# File 'app/models/account.rb', line 296

def is_admin?
  inherited_role_names.include?('admin')
end

#is_customer?Boolean

Returns:

  • (Boolean)


292
293
294
# File 'app/models/account.rb', line 292

def is_customer?
  party.is_a?(Customer)
end

#is_employee?Boolean

Returns:

  • (Boolean)


288
289
290
# File 'app/models/account.rb', line 288

def is_employee?
  party.is_a?(Employee)
end

#is_manager?Boolean

Returns:

  • (Boolean)


300
301
302
# File 'app/models/account.rb', line 300

def is_manager?
  is_admin? || inherited_role_names.any? { |rn| rn =~ /_manager$/ && rn != 'item_manager' }
end

#legacy_sha1_password?Boolean

State A: legacy restful_authentication_sha1 hex digest, no
bcrypt wrapping. The Stage 2 data migration (see migration plan)
converts every State-A row to State B; expected to be empty
post-migration.

Returns:

  • (Boolean)


230
231
232
# File 'app/models/account.rb', line 230

def legacy_sha1_password?
  encrypted_password.present? && !bcrypt_shaped_password?
end

#login_is_email?Boolean

Returns:

  • (Boolean)


185
186
187
# File 'app/models/account.rb', line 185

def 
   =~ RFC822::EMAIL
end

#notify(options) ⇒ Object



428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
# File 'app/models/account.rb', line 428

def notify(options)
  mailers = []
  case options[:activity]
  when 'update_password'
    mailers << AccountMailer.password_changed(id)
  when 'update_email'
    # notify both old and new of the change if they are different
    mailers << AccountMailer.email_changed(id, options[:old_email], options[:old_login], options[:old_email]) if options[:old_email] != email
    mailers << AccountMailer.email_changed(id, options[:old_email], options[:old_login], email) if (options[:old_email] != email) || (options[:old_login] != )
  when 'created_account'
    mailers << AccountMailer.(id)
  end

  mailers.each { |m| m.deliver_later(wait: 10.seconds) }
end

#obfuscated_emailObject



499
500
501
# File 'app/models/account.rb', line 499

def obfuscated_email
  email&.gsub(/(?<=.{2}).*@.*(?=\S{2})/, '****@****')
end

#omniauth_provider_icon_basenamesObject



381
382
383
# File 'app/models/account.rb', line 381

def omniauth_provider_icon_basenames
  Authentication::PROVIDERS.select { |p, _h| authentications.find_by(provider: p) }.map { |_p, h| h[:icon] }
end

#partyParty

Returns:

See Also:



104
# File 'app/models/account.rb', line 104

belongs_to :party, optional: true

#password_required?Boolean

Returns:

  • (Boolean)


339
340
341
342
343
344
345
346
347
# File 'app/models/account.rb', line 339

def password_required?
  return false if authentication_mode == AUTH_GOOGLE

  res = true
  res = false if authentications.any?
  res = false if encrypted_password.blank?
  res = true if require_password
  res && (!persisted? || !password.nil? || !password_confirmation.nil?)
end

#pending_confirmation?Boolean

Returns:

  • (Boolean)


326
327
328
# File 'app/models/account.rb', line 326

def pending_confirmation?
  invitation_token.present?
end

#push_login_to_emailObject



490
491
492
493
# File 'app/models/account.rb', line 490

def 
  # Set the email to the login if login is an email, also fixes it if login changed
  self.email =  if email.blank? && 
end

#respond_to?(method_id, include_private = false) ⇒ Boolean

Returns:

  • (Boolean)


385
386
387
# File 'app/models/account.rb', line 385

def respond_to?(method_id, include_private = false)
  /^is_\w+?/.match?(method_id.to_s) || super
end

#role_inheritanceObject (protected)



586
587
588
589
590
591
592
593
594
595
# File 'app/models/account.rb', line 586

def role_inheritance
  roles.each do |r|
    arids = r.ancestors_ids # Ancestors of this role
    # Any ancestor present? then we don't need to keep this role
    if (res = (role_ids & arids)).present?
      role_names = Role.where(id: res).pluck(:name)
      errors.add(:base, "Role #{r.name} is already inherited by role #{role_names.join(', ')} and cannot be assigned")
    end
  end
end

#rolesActiveRecord::Relation<Role>

Returns:

  • (ActiveRecord::Relation<Role>)

See Also:



111
# File 'app/models/account.rb', line 111

has_and_belongs_to_many :roles, after_add: :touch_me, after_remove: :touch_me, inverse_of: :accounts

#set_default_marketing_preferences(locale) ⇒ Object



270
271
272
273
274
275
276
# File 'app/models/account.rb', line 270

def set_default_marketing_preferences(locale)
  self. = if (locale == :'en-CA') || (locale == :'fr-CA')
                             false
                           else
                             true
                           end
end

#set_defaultsObject (protected)



563
564
565
566
# File 'app/models/account.rb', line 563

def set_defaults
  self.authentication_mode ||= AUTH_INTERNAL
  
end

#signed_login_url(target_url) ⇒ Object

Build a magic-login URL — the embedded login_token authenticates this
account when the URL is visited. The token is a single-use
generates_token_for(:magic_login) value (see the declaration above):
purpose-tagged, 7-day TTL, and killed by the next sign-in. No DB column.

The TTL lives on the class-level declaration, not here — there's no
per-call override, so a caller that needs a different lifetime must add a
new generates_token_for purpose rather than pass a kwarg.

Example:
account.signed_login_url(retrieve_my_cart_url(host: WEB_HOSTNAME))



472
473
474
475
476
477
478
479
480
481
482
# File 'app/models/account.rb', line 472

def (target_url)
  uri = Addressable::URI.parse(target_url)
  new_query_ar = uri.query ? Addressable::URI.form_unencode(uri.query) : []
  # Drop any pre-existing `login_token` so we never emit a URL with two of
  # them (ambiguous to the auth strategy) and so re-signing an already-signed
  # URL doesn't leak the stale token alongside the fresh one.
  new_query_ar.reject! { |key, _| key == 'login_token' }
  new_query_ar << ['login_token', generate_token_for(:magic_login)]
  uri.query = Addressable::URI.form_encode(new_query_ar)
  uri.to_s
end

#timeout_inObject (protected)



609
610
611
612
613
614
615
616
617
# File 'app/models/account.rb', line 609

def timeout_in
  # Magic-link / guest-style sessions: tightest. Employee CRM sessions
  # touch customer PII so they get a much shorter window than the
  # storefront default. Customers get the configured Devise default.
  return 30.minutes if auth_token_required?
  return 8.hours if is_employee?

  Devise.timeout_in
end

#touch_me(_role) ⇒ Object (protected)



582
583
584
# File 'app/models/account.rb', line 582

def touch_me(_role)
  touch unless new_record?
end

#update_matching_contact_point_if_neededObject (protected)



568
569
570
571
572
573
# File 'app/models/account.rb', line 568

def update_matching_contact_point_if_needed
  return unless email_changed? && valid?

  cp = party.contact_points.where(detail: email_was, category: 'email').first
  cp&.update_attribute!(:detail, email)
end

#valid_password?(password) ⇒ Boolean

Devise verifies passwords by computing bcrypt over the supplied
plaintext and comparing to encrypted_password. We override to
also accept the two transitional states from the bcrypt migration:

  • State A (legacy SHA1): recompute the legacy
    restful_authentication_sha1 digest from the supplied
    plaintext + this row's password_salt + the project pepper,
    and secure_compare against encrypted_password.
  • State B (wrapped bcrypt): recompute the same legacy SHA1
    digest, then bcrypt-verify it against encrypted_password.
    This is the Dropbox-style wrap — bcrypt-cost protection
    without re-hashing the user's plaintext.

On a successful match in either transitional state, stash the
plaintext on the instance so after_database_authentication
can rehash the row to pure bcrypt and clear the salt. Verifying
is read-only by contract (Devise calls it from many code paths,
not all of which want a side-effecting save), so the rehash is
deferred to the post-authentication callback.

Returns:

  • (Boolean)


253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
# File 'app/models/account.rb', line 253

def valid_password?(password)
  if legacy_sha1_password?
    Appsignal.increment_counter('password_verify_state', 1, state: 'legacy_sha1') if defined?(Appsignal)
    verified = Devise.secure_compare(encrypted_password, legacy_sha1_digest(password))
    @needs_password_rehash = password if verified
    verified
  elsif wrapped_password?
    Appsignal.increment_counter('password_verify_state', 1, state: 'wrapped_bcrypt') if defined?(Appsignal)
    verified = ::BCrypt::Password.new(encrypted_password) == legacy_sha1_digest(password)
    @needs_password_rehash = password if verified
    verified
  else
    Appsignal.increment_counter('password_verify_state', 1, state: 'pure_bcrypt') if defined?(Appsignal)
    super
  end
end

#wrapped_password?Boolean

State B (Dropbox-style wrap): bcrypt over the legacy SHA1 digest.
Detected by: bcrypt shape AND a still-populated password_salt
column (the salt is only needed to recompute the SHA1 pre-image
at verify time; we clear it once the row is rehashed to pure
bcrypt in rehash_legacy_password!).

Returns:

  • (Boolean)


222
223
224
# File 'app/models/account.rb', line 222

def wrapped_password?
  bcrypt_shaped_password? && password_salt.present?
end