Class: Account
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- Account
- 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_passwordis the legacyrestful_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 = 10historically,
before we repurposedstretchesto mean bcrypt cost). Pinned here
so the verify-time reconstruction inlegacy_sha1_digestdoesn't
drift ifDevise.stretchesis 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, socan?(: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
- #authentication_mode ⇒ Integer readonly
- #email ⇒ String readonly
-
#email_reset_instructions ⇒ Object
Returns the value of attribute email_reset_instructions.
-
#invitation_code ⇒ Object
Returns the value of attribute invitation_code.
- #login ⇒ String readonly
-
#marketing_sign_up ⇒ Object
Returns the value of attribute marketing_sign_up.
-
#password ⇒ Object
readonly
from devise validatable, we only keep password validations, not email since they require uniqueness if present, which we do not.
- #password_confirmation ⇒ String? readonly
-
#require_password ⇒ Object
Returns the value of attribute require_password.
-
#skip_notification ⇒ Object
Returns the value of attribute skip_notification.
Belongs to collapse
-
#employee ⇒ Employee?
The employee this account belongs to.
-
#party ⇒ Party?
The party this account belongs to.
Methods included from Models::Auditable
Has many collapse
-
#api_authentications ⇒ ActiveRecord::Relation<ApiAuthentication>
The associated api authentications.
-
#authentications ⇒ ActiveRecord::Relation<Authentication>
The associated authentications.
-
#invitations ⇒ ActiveRecord::Relation<Account>
The associated invitations.
Has and belongs to many collapse
-
#roles ⇒ ActiveRecord::Relation<Role>
The associated roles.
Delegated Instance Attributes collapse
-
#can? ⇒ Object
Alias for Ability#can?.
-
#cannot? ⇒ Object
Alias for Ability#cannot?.
-
#customer ⇒ Object
Alias for Party#customer.
Class Method Summary collapse
-
.account_api_signed_in(api_authentication_token) ⇒ Account?
Finds the account for an API token, destroying the token if expired.
-
.active_accounts ⇒ ActiveRecord::Relation<Account>
A relation of Accounts that are active accounts.
-
.email_logins ⇒ ActiveRecord::Relation<Account>
A relation of Accounts that are email logins.
-
.get_unique_login_for_email(email) ⇒ String
Builds a unique login derived from an email address.
Instance Method Summary collapse
-
#ability ⇒ Ability
The ability for this account's party.
-
#account_created_notify_reps_and_master_account ⇒ void
Sends the created-account notification unless the party is an employee or notifications are skipped.
-
#active_for_authentication? ⇒ Boolean
Whether the account may sign in (not disabled).
-
#after_database_authentication ⇒ Object
Devise calls this after a successful database authentication (database_authenticatable strategy →
resource.after_database_authentication). -
#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.
-
#api_sign_out!(api_authentication_token) ⇒ void
Destroys the API authentication matching the token.
-
#apply_omniauth(omniauth) ⇒ void
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.
-
#auth_token_required? ⇒ Boolean
Whether a magic-login auth token is required.
-
#authentication_internal? ⇒ Boolean
Whether the account uses internal authentication.
-
#authentication_methods ⇒ Array<String>
The available authentication methods.
-
#authentication_mode_name ⇒ String?
The humanized authentication mode.
-
#bcrypt_shaped_password? ⇒ Boolean
Whether
encrypted_passwordis bcrypt-shaped. -
#can_access_mcp? ⇒ Boolean
MCP (Model Context Protocol) access methods Used for AI assistant integrations like Cursor/Claude.
-
#can_impersonate?(customer_account) ⇒ Boolean
Whether this account may impersonate the customer.
-
#check_for_reset_instructions ⇒ void
protected
Sends password reset instructions when requested.
-
#confirmation_required? ⇒ Boolean
protected
Whether Devise confirmation is required.
-
#consume_password_rehash! ⇒ Boolean
Idempotent rehash of a transitional-state password to pure bcrypt.
-
#effective_mcp_services ⇒ Array<String>
Service keys this account may use over MCP.
-
#fetch_inherited_role_names ⇒ Array<String>
The names of the inherited roles.
-
#fully_enabled? ⇒ Boolean
Whether the account has a login and credentials.
-
#generate_mcp_token! ⇒ String
Generates an MCP access token for this account.
-
#has_role?(*roles_in_question, admin_check: true) ⇒ Boolean
Whether the account has any of the given roles.
- #headers_for(_action) ⇒ Hash
-
#inactive_message ⇒ Symbol, String
The Devise failure message for a rejected sign-in.
-
#is_admin? ⇒ Boolean
Whether the account has the admin role.
-
#is_customer? ⇒ Boolean
Whether the party is a Customer.
-
#is_employee? ⇒ Boolean
Whether the party is an Employee.
-
#is_manager? ⇒ Boolean
Whether the account has any manager-level role.
-
#legacy_sha1_password? ⇒ Boolean
State A: legacy
restful_authentication_sha1hex digest, no bcrypt wrapping. -
#login_is_email? ⇒ Boolean
Whether the login looks like an email address.
-
#method_missing(method_id, *arguments) ⇒ Object
protected
Defines
is_<role>?predicate methods on first use. -
#notify(options) ⇒ void
Sends account-related notification emails based on the given activity.
-
#obfuscated_email ⇒ String?
The email with the middle masked out.
-
#omniauth_provider_icon_basenames ⇒ Array<String>
Icon basenames for linked social providers.
-
#password_required? ⇒ Boolean
Whether a password is required on save.
-
#pending_confirmation? ⇒ Boolean
Whether the account has a pending invitation.
-
#push_login_to_email ⇒ void
Copies the login onto email when the login is an email address.
-
#respond_to?(method_id, include_private = false) ⇒ Boolean
Whether the account responds to the method.
-
#role_derived_mcp_services ⇒ Array<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.
-
#role_inheritance ⇒ void
protected
Validation: rejects roles already inherited through another assigned role.
-
#set_default_marketing_preferences(locale) ⇒ void
Sets the default marketing sign-up preference from the locale.
-
#set_defaults ⇒ void
protected
Applies default attribute values before validation.
-
#signed_login_url(target_url) ⇒ String
Build a magic-login URL — the embedded
login_tokenauthenticates this account when the URL is visited. -
#timeout_in ⇒ ActiveSupport::Duration
protected
The session timeout for this account.
-
#touch_me(_role) ⇒ void
protected
Touches the account after a role join change.
-
#update_matching_contact_point_if_needed ⇒ void
protected
Keeps the matching email contact point in sync after an email change.
-
#valid_password?(password) ⇒ Boolean
Devise verifies passwords by computing bcrypt over the supplied plaintext and comparing to
encrypted_password. -
#validate_permitted_mcp_services ⇒ Object
Mirrors ApiAuthentication#validate_permitted_services — a typo'd key must not silently become a permission that never matches.
-
#wrapped_password? ⇒ Boolean
State B (Dropbox-style wrap): bcrypt over the legacy SHA1 digest.
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
Methods included from Models::AfterCommittable
Methods included from Models::EventPublishable
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.
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_mode ⇒ Integer (readonly)
137 |
# File 'app/models/account.rb', line 137 validates :authentication_mode, presence: true, inclusion: { in: [AUTH_INTERNAL, AUTH_GOOGLE] } |
#email ⇒ String (readonly)
141 |
# File 'app/models/account.rb', line 141 validates :email, presence: true, email_format: true |
#email_reset_instructions ⇒ Object
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_code ⇒ Object
Returns the value of attribute invitation_code.
156 157 158 |
# File 'app/models/account.rb', line 156 def invitation_code @invitation_code end |
#login ⇒ String (readonly)
139 |
# File 'app/models/account.rb', line 139 validates :login, presence: true, uniqueness: true |
#marketing_sign_up ⇒ Object
Returns the value of attribute marketing_sign_up.
156 157 158 |
# File 'app/models/account.rb', line 156 def marketing_sign_up @marketing_sign_up end |
#password ⇒ Object (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_confirmation ⇒ String? (readonly)
153 |
# File 'app/models/account.rb', line 153 validates :password_confirmation, presence: true, if: -> { password.present? } |
#require_password ⇒ Object
Returns the value of attribute require_password.
156 157 158 |
# File 'app/models/account.rb', line 156 def require_password @require_password end |
#skip_notification ⇒ Object
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.
179 180 181 182 183 184 185 186 187 188 189 190 191 192 |
# File 'app/models/account.rb', line 179 def self.account_api_signed_in(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.account end end res end |
.active_accounts ⇒ ActiveRecord::Relation<Account>
A relation of Accounts that are active accounts. Active Record Scope
128 |
# File 'app/models/account.rb', line 128 scope :active_accounts, -> { where.not(disabled: true) } |
.email_logins ⇒ ActiveRecord::Relation<Account>
A relation of Accounts that are email logins. Active Record Scope
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.
198 199 200 201 202 203 204 205 206 207 208 209 210 211 |
# File 'app/models/account.rb', line 198 def self.get_unique_login_for_email(email) login = email if Account.where(login: email).exists? name_part, = email.split('@') base_login = name_part counter = 0 login = base_login while Account.where(login: login).exists? counter += 1 login = "#{base_login}-#{counter}" end end login end |
Instance Method Details
#ability ⇒ Ability
Returns 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_account ⇒ void
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 account_created_notify_reps_and_master_account 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).
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_authentication ⇒ Object
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_authentications ⇒ ActiveRecord::Relation<ApiAuthentication>
Returns the associated api authentications.
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
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.
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.
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 login.blank? self.login = 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.
573 574 575 |
# File 'app/models/account.rb', line 573 def auth_token_required? login.present? && authentications.empty? && encrypted_password.blank? && !is_employee? end |
#authentication_internal? ⇒ Boolean
Returns 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_methods ⇒ Array<String>
Returns 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_name ⇒ String?
Returns the humanized authentication mode.
369 370 371 |
# File 'app/models/account.rb', line 369 def authentication_mode_name %w[Internal Google][authentication_mode] end |
#authentications ⇒ ActiveRecord::Relation<Authentication>
Returns 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.
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?
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
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.
351 352 353 354 355 356 |
# File 'app/models/account.rb', line 351 def can_impersonate?(customer_account) is_manager? || is_customer_service_rep? || party_id == customer_account.customer.primary_sales_rep_id || party_id == customer_account.customer.secondary_sales_rep_id end |
#cannot? ⇒ Object
Alias for Ability#cannot?
171 |
# File 'app/models/account.rb', line 171 delegate :can?, :cannot?, to: :ability |
#check_for_reset_instructions ⇒ void (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.
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).
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 |
#customer ⇒ Object
Alias for Party#customer
327 |
# File 'app/models/account.rb', line 327 delegate :customer, to: :party |
#effective_mcp_services ⇒ Array<String>
Returns 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 |
#employee ⇒ Employee?
Returns 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_names ⇒ Array<String>
Returns 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.
374 375 376 |
# File 'app/models/account.rb', line 374 def fully_enabled? login.present? && (encrypted_password.present? || authentications.present?) end |
#generate_mcp_token! ⇒ String
Generates an MCP access token for this account.
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.
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
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_message ⇒ Symbol, String
Returns the Devise failure message for a rejected sign-in.
225 226 227 228 229 |
# File 'app/models/account.rb', line 225 def return super unless disabled? I18n.t('devise.failure.account_disabled', phone: CompanyConstants::PHONE[:usa]) end |
#invitations ⇒ ActiveRecord::Relation<Account>
Returns 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.
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.
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.
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.
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.
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.
214 215 216 |
# File 'app/models/account.rb', line 214 def login_is_email? login =~ RFC822::EMAIL end |
#notify(options) ⇒ void
This method returns an undefined value.
Sends account-related notification emails based on the given activity.
556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 |
# File 'app/models/account.rb', line 556 def notify() mailers = [] case [: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, [:old_email], [:old_login], [:old_email]) if [:old_email] != email mailers << AccountMailer.email_changed(id, [:old_email], [:old_login], email) if ([:old_email] != email) || ([:old_login] != login) when 'created_account' mailers << AccountMailer.created_account(id) end mailers.each { |m| m.deliver_later(wait: 10.seconds) } end |
#obfuscated_email ⇒ String?
Returns the email with the middle masked out.
639 640 641 |
# File 'app/models/account.rb', line 639 def email&.gsub(/(?<=.{2}).*@.*(?=\S{2})/, '****@****') end |
#omniauth_provider_icon_basenames ⇒ Array<String>
Returns 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 |
#party ⇒ Party?
Returns 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.
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.
379 380 381 |
# File 'app/models/account.rb', line 379 def pending_confirmation? invitation_token.present? end |
#push_login_to_email ⇒ void
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 push_login_to_email # Set the email to the login if login is an email, also fixes it if login changed self.email = login if email.blank? && login_is_email? end |
#respond_to?(method_id, include_private = false) ⇒ Boolean
Returns 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_services ⇒ Array<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.
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_inheritance ⇒ void (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 |
#roles ⇒ ActiveRecord::Relation<Role>
Returns 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.
309 310 311 312 313 314 315 |
# File 'app/models/account.rb', line 309 def set_default_marketing_preferences(locale) self.marketing_sign_up = if (locale == :'en-CA') || (locale == :'fr-CA') false else true end end |
#set_defaults ⇒ void (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 push_login_to_email 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.
603 604 605 606 607 608 609 610 611 612 613 |
# File 'app/models/account.rb', line 603 def signed_login_url(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_in ⇒ ActiveSupport::Duration (protected)
Returns 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.
736 737 738 |
# File 'app/models/account.rb', line 736 def touch_me(_role) touch unless new_record? end |
#update_matching_contact_point_if_needed ⇒ void (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_sha1digest from the supplied
plaintext + this row'spassword_salt+ the project pepper,
andsecure_compareagainstencrypted_password. - State B (wrapped bcrypt): recompute the same legacy SHA1
digest, then bcrypt-verify it againstencrypted_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.
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_services ⇒ Object
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!).
254 255 256 |
# File 'app/models/account.rb', line 254 def wrapped_password? bcrypt_shaped_password? && password_salt.present? end |