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
MCP_DEFAULT_SERVICES =

The per-user MCP gate, below can_access_mcp?: the role says "may use MCP at
all", this says "may use these tools". Managed admin-only from the employee
System tab. Shares ApiAuthentication's vocabulary so the two layers intersect,
and always grants the default on top (same rule as
ApiAuthentication#effective_services), so an unconfigured account gets exactly
'content' rather than nothing and no existing user loses access on deploy.

ApiAuthentication::DEFAULT_SERVICES
MCP_DERIVED_ONLY_SERVICES =

Services that may ONLY be derived from CRM permissions, never granted by
ticking a box. Support cases expose customer PII, so can?(:read, SupportCase)
is the single source of truth (as it is for Sunny) — persisting the key must
not become a back door around that check.

%w[support_cases].freeze

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Constants included from Models::Schedulable

Models::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 Models::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)

Defines is_<role>? predicate methods on first use.

Parameters:

  • method_id (Symbol)

    the missing method name

  • arguments (Array<Object>)

    the call arguments

Returns:

  • (Object)

    the result of the defined predicate



759
760
761
762
763
764
765
766
767
768
769
# File 'app/models/account.rb', line 759

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_modeInteger (readonly)

Returns:

  • (Integer)


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

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

#emailString (readonly)

Returns:

  • (String)


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

validates :email, presence: true, email_format: true

#email_reset_instructionsObject

Returns the value of attribute email_reset_instructions.



156
157
158
# File 'app/models/account.rb', line 156

def email_reset_instructions
  @email_reset_instructions
end

#invitation_codeObject

Returns the value of attribute invitation_code.



156
157
158
# File 'app/models/account.rb', line 156

def invitation_code
  @invitation_code
end

#loginString (readonly)

Returns:

  • (String)


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

validates :login, presence: true, uniqueness: true

#marketing_sign_upObject

Returns the value of attribute marketing_sign_up.



156
157
158
# File 'app/models/account.rb', line 156

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 })


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

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

#password_confirmationString? (readonly)

Returns:

  • (String, nil)


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

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

#require_passwordObject

Returns the value of attribute require_password.



156
157
158
# File 'app/models/account.rb', line 156

def require_password
  @require_password
end

#skip_notificationObject

Returns the value of attribute skip_notification.



156
157
158
# File 'app/models/account.rb', line 156

def skip_notification
  @skip_notification
end

Class Method Details

.account_api_signed_in(api_authentication_token) ⇒ Account?

Finds the account for an API token, destroying the token if expired.

Parameters:

  • api_authentication_token (String)

    the API authentication token

Returns:

  • (Account, nil)

    the account, or nil when missing or expired



179
180
181
182
183
184
185
186
187
188
189
190
191
192
# File 'app/models/account.rb', line 179

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:



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

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:



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

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

.get_unique_login_for_email(email) ⇒ String

Builds a unique login derived from an email address.

Parameters:

  • email (String)

    the email address to derive a login from

Returns:

  • (String)

    a login not yet taken



198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'app/models/account.rb', line 198

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

#abilityAbility

Returns the ability for this account's party.

Returns:

  • (Ability)

    the ability for this account's party



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

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

#account_created_notify_reps_and_master_accountvoid

This method returns an undefined value.

Sends the created-account notification unless the party is an employee
or notifications are skipped.



619
620
621
622
623
# File 'app/models/account.rb', line 619

def 
  return if party&.is_employee? || skip_notification

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

#active_for_authentication?Boolean

Returns whether the account may sign in (not disabled).

Returns:

  • (Boolean)

    whether the account may sign in (not disabled)



219
220
221
222
# File 'app/models/account.rb', line 219

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.



659
660
661
662
# File 'app/models/account.rb', line 659

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

#api_authenticationsActiveRecord::Relation<ApiAuthentication>

Returns the associated api authentications.

Returns:



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

has_many :api_authentications, dependent: :destroy

#api_sign_in!(is_guest = false) ⇒ String

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

Parameters:

  • is_guest (Boolean) (defaults to: false)

    whether the API session is a guest session

Returns:

  • (String)

    the new API authentication token



459
460
461
462
463
464
465
# File 'app/models/account.rb', line 459

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) ⇒ void

This method returns an undefined value.

Destroys the API authentication matching the token.

Parameters:

  • api_authentication_token (String)

    the API authentication token



471
472
473
474
475
# File 'app/models/account.rb', line 471

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) ⇒ void

This method returns an undefined value.

Wires a social-media login onto this account: assigns login/email from the
omniauth payload (only when blank), builds the Authentication join, and
stashes the provider avatar URL. The person's NAME is seeded onto the party
(the name of record) by the controllers that own the party instance — see
AuthenticationsController#adopt_oauth_identity_onto_context_user — not here.

Parameters:

  • omniauth (Hash)

    the omniauth auth hash



413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
# File 'app/models/account.rb', line 413

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

  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 whether a magic-login auth token is required.

Returns:

  • (Boolean)

    whether a magic-login auth token is required



573
574
575
# File 'app/models/account.rb', line 573

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

#authentication_internal?Boolean

Returns whether the account uses internal authentication.

Returns:

  • (Boolean)

    whether the account uses internal authentication



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

def authentication_internal?
  authentication_mode == AUTH_INTERNAL
end

#authentication_methodsArray<String>

Returns the available authentication methods.

Returns:

  • (Array<String>)

    the available authentication methods



445
446
447
448
449
450
451
452
453
# File 'app/models/account.rb', line 445

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_nameString?

Returns the humanized authentication mode.

Returns:

  • (String, nil)

    the humanized authentication mode



369
370
371
# File 'app/models/account.rb', line 369

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

#authenticationsActiveRecord::Relation<Authentication>

Returns the associated authentications.

Returns:

  • (ActiveRecord::Relation<Authentication>)

    the associated authentications



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

has_many :authentications, dependent: :destroy

#bcrypt_shaped_password?Boolean

Returns whether encrypted_password is bcrypt-shaped.

Returns:

  • (Boolean)

    whether encrypted_password is bcrypt-shaped



245
246
247
# File 'app/models/account.rb', line 245

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:



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

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)


480
481
482
# File 'app/models/account.rb', line 480

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

#can_impersonate?(customer_account) ⇒ Boolean

Returns whether this account may impersonate the customer.

Parameters:

  • customer_account (Account)

    the customer account to impersonate

Returns:

  • (Boolean)

    whether this account may impersonate the customer



351
352
353
354
355
356
# File 'app/models/account.rb', line 351

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:



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

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

#check_for_reset_instructionsvoid (protected)

This method returns an undefined value.

Sends password reset instructions when requested.



725
726
727
728
729
730
# File 'app/models/account.rb', line 725

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 whether Devise confirmation is required.

Returns:

  • (Boolean)

    whether Devise confirmation is required



697
698
699
700
701
702
# File 'app/models/account.rb', line 697

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.



668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
# File 'app/models/account.rb', line 668

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:



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

delegate :customer, to: :party

#effective_mcp_servicesArray<String>

Returns service keys this account may use over MCP.

Returns:

  • (Array<String>)

    service keys this account may use over MCP.



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

def effective_mcp_services
  ((permitted_mcp_services.to_a - MCP_DERIVED_ONLY_SERVICES) |
    MCP_DEFAULT_SERVICES | role_derived_mcp_services).sort
end

#employeeEmployee?

Returns the employee this account belongs to.

Returns:

  • (Employee, nil)

    the employee this account belongs to



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

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

#fetch_inherited_role_namesArray<String>

Returns the names of the inherited roles.

Returns:

  • (Array<String>)

    the names of the inherited roles



634
635
636
# File 'app/models/account.rb', line 634

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

#fully_enabled?Boolean

Returns whether the account has a login and credentials.

Returns:

  • (Boolean)

    whether the account has a login and credentials



374
375
376
# File 'app/models/account.rb', line 374

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

#generate_mcp_token!String

Generates an MCP access token for this account.

Returns:

  • (String)

    the new API authentication token

Raises:

  • (RuntimeError)

    when the account lacks MCP access



542
543
544
545
546
# File 'app/models/account.rb', line 542

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 whether the account has any of the given roles.

Parameters:

  • roles_in_question (Array<String>)

    role name(s) to check

  • admin_check (Boolean) (defaults to: true)

    whether the admin role counts as a match

Returns:

  • (Boolean)

    whether the account has any of the given roles



361
362
363
364
365
366
# File 'app/models/account.rb', line 361

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) ⇒ Hash

Parameters:

  • _action (Object)

    unused mailer action

Returns:

  • (Hash)

    mailer headers



386
387
388
389
390
391
392
# File 'app/models/account.rb', line 386

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_messageSymbol, String

Returns the Devise failure message for a rejected sign-in.

Returns:

  • (Symbol, String)

    the Devise failure message for a rejected sign-in



225
226
227
228
229
# File 'app/models/account.rb', line 225

def inactive_message
  return super unless disabled?

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

#invitationsActiveRecord::Relation<Account>

Returns the associated invitations.

Returns:

  • (ActiveRecord::Relation<Account>)

    the associated invitations



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

has_many :invitations, class_name: 'Account', as: :invited_by, dependent: :nullify

#is_admin?Boolean

Returns whether the account has the admin role.

Returns:

  • (Boolean)

    whether the account has the admin role



340
341
342
# File 'app/models/account.rb', line 340

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

#is_customer?Boolean

Returns whether the party is a Customer.

Returns:

  • (Boolean)

    whether the party is a Customer



335
336
337
# File 'app/models/account.rb', line 335

def is_customer?
  party.is_a?(Customer)
end

#is_employee?Boolean

Returns whether the party is an Employee.

Returns:

  • (Boolean)

    whether the party is an Employee



330
331
332
# File 'app/models/account.rb', line 330

def is_employee?
  party.is_a?(Employee)
end

#is_manager?Boolean

Returns whether the account has any manager-level role.

Returns:

  • (Boolean)

    whether the account has any manager-level role



345
346
347
# File 'app/models/account.rb', line 345

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)


262
263
264
# File 'app/models/account.rb', line 262

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

#login_is_email?Boolean

Returns whether the login looks like an email address.

Returns:

  • (Boolean)

    whether the login looks like an email address



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

def 
   =~ RFC822::EMAIL
end

#notify(options) ⇒ void

This method returns an undefined value.

Sends account-related notification emails based on the given activity.

Parameters:

  • options (Hash)

    notification details

Options Hash (options):

  • activity (String)

    the activity that triggered the notification
    ('update_password', 'update_email', 'created_account')

  • old_email (String, nil)

    the previous email address, used for 'update_email'

  • old_login (String, nil)

    the previous login, used for 'update_email'



556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
# File 'app/models/account.rb', line 556

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_emailString?

Returns the email with the middle masked out.

Returns:

  • (String, nil)

    the email with the middle masked out



639
640
641
# File 'app/models/account.rb', line 639

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

#omniauth_provider_icon_basenamesArray<String>

Returns icon basenames for linked social providers.

Returns:

  • (Array<String>)

    icon basenames for linked social providers



433
434
435
# File 'app/models/account.rb', line 433

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

#partyParty?

Returns the party this account belongs to.

Returns:

  • (Party, nil)

    the party this account belongs to



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

belongs_to :party, optional: true

#password_required?Boolean

Returns whether a password is required on save.

Returns:

  • (Boolean)

    whether a password is required on save



395
396
397
398
399
400
401
402
403
# File 'app/models/account.rb', line 395

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 whether the account has a pending invitation.

Returns:

  • (Boolean)

    whether the account has a pending invitation



379
380
381
# File 'app/models/account.rb', line 379

def pending_confirmation?
  invitation_token.present?
end

#push_login_to_emailvoid

This method returns an undefined value.

Copies the login onto email when the login is an email address.



628
629
630
631
# File 'app/models/account.rb', line 628

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 whether the account responds to the method.

Parameters:

  • method_id (Symbol, String)

    the method being probed

  • include_private (Boolean) (defaults to: false)

    whether to include private methods

Returns:

  • (Boolean)

    whether the account responds to the method



440
441
442
# File 'app/models/account.rb', line 440

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

#role_derived_mcp_servicesArray<String>

Services the user's existing CRM permissions already imply, resolved the same
way Sunny does (see Crm::AssistantChatController#available_chat_services):
support cases expose customer PII and message bodies, so MCP grants them on
the same CRM SupportCase read permission instead of a separate switch. One
person then reaches the same data whether they come via Sunny or an MCP client.

Returns:

  • (Array<String>)


512
513
514
515
516
517
518
519
520
521
# File 'app/models/account.rb', line 512

def role_derived_mcp_services
  # `can?` delegates to the already-memoized `ability` (built on `party`, which IS
  # the Employee whenever is_employee? holds), so this reuses one Ability per account
  # instead of constructing a second one that could drift from it.
  if is_employee? && can?(:read, SupportCase)
    %w[support_cases]
  else
    []
  end
end

#role_inheritancevoid (protected)

This method returns an undefined value.

Validation: rejects roles already inherited through another assigned role.



743
744
745
746
747
748
749
750
751
752
# File 'app/models/account.rb', line 743

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 the associated roles.

Returns:

  • (ActiveRecord::Relation<Role>)

    the associated roles



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

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

#set_default_marketing_preferences(locale) ⇒ void

This method returns an undefined value.

Sets the default marketing sign-up preference from the locale.

Parameters:

  • locale (Symbol)

    the visitor locale



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

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

#set_defaultsvoid (protected)

This method returns an undefined value.

Applies default attribute values before validation.



707
708
709
710
# File 'app/models/account.rb', line 707

def set_defaults
  self.authentication_mode ||= AUTH_INTERNAL
  
end

#signed_login_url(target_url) ⇒ String

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.

Examples:

.(retrieve_my_cart_url(host: WEB_HOSTNAME))

Parameters:

  • target_url (String)

    the URL to embed the login token in

Returns:

  • (String)

    the signed URL



603
604
605
606
607
608
609
610
611
612
613
# File 'app/models/account.rb', line 603

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_inActiveSupport::Duration (protected)

Returns the session timeout for this account.

Returns:

  • (ActiveSupport::Duration)

    the session timeout for this account



772
773
774
775
776
777
778
779
780
# File 'app/models/account.rb', line 772

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) ⇒ void (protected)

This method returns an undefined value.

Touches the account after a role join change.

Parameters:

  • _role (Role)

    the added or removed role (unused)



736
737
738
# File 'app/models/account.rb', line 736

def touch_me(_role)
  touch unless new_record?
end

#update_matching_contact_point_if_neededvoid (protected)

This method returns an undefined value.

Keeps the matching email contact point in sync after an email change.



715
716
717
718
719
720
# File 'app/models/account.rb', line 715

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.

Parameters:

  • password (String)

    the plaintext password to verify

Returns:

  • (Boolean)

    whether the password matches



288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
# File 'app/models/account.rb', line 288

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

#validate_permitted_mcp_servicesObject

Mirrors ApiAuthentication#validate_permitted_services — a typo'd key must not
silently become a permission that never matches. Also rejects derived-only
services, so support_cases can't be persisted as a direct grant and slip
past the CRM SupportCase check.



528
529
530
531
532
533
534
535
536
# File 'app/models/account.rb', line 528

def validate_permitted_mcp_services
  return if permitted_mcp_services.blank?

  grantable = ApiAuthentication::UPSTREAM_SERVICES.keys - MCP_DERIVED_ONLY_SERVICES
  invalid = permitted_mcp_services - grantable
  return if invalid.empty?

  errors.add(:permitted_mcp_services, "contains services that cannot be granted directly: #{invalid.join(', ')}")
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)


254
255
256
# File 'app/models/account.rb', line 254

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