Files
sure/app/models/user.rb
Guillem Arias Fauste c29380ce57 feat(dashboard): masonry packing + per-widget size controls (#2328)
* feat(dashboard): masonry packing + per-widget size controls

In two-column mode the dashboard used a row-based CSS grid, so cards
stretched to equal row height and left dead space (e.g. the Net Worth
chart padded out to match the tall Balance Sheet table). Replace the
row-based layout with masonry packing and add per-widget size guardrails.

- Masonry: CSS grid with computed row-spans + grid-auto-flow: dense, driven
  by a dashboard-masonry Stimulus controller (ResizeObserver + turbo:frame-load).
  The DOM stays a single flat list, so drag/keyboard reorder is unaffected.
  Active only in multi-column mode; single column falls back to normal flow.
- Internal sizing: the net worth chart height is now driven by a
  --dash-widget-h CSS var (fixes an inert flex-1) so the card no longer pads
  out below the chart.
- Guardrails: per-widget layout metadata (col_span, grow, min_height,
  width_toggle) in PagesController, with per-user overrides persisted under
  preferences["dashboard_section_layout"], deep-merged so width and height coexist.
- Size menu: a hover control on size-capable cards — Width (Half/Full) for the
  cashflow sankey and net worth chart, Height (Compact/Auto/Tall) for grow
  widgets. The sankey defaults to full width.

Adds model + controller tests for preference persistence and i18n keys.

* refactor(dashboard): redesign size menu with segmented controls

The size menu used a plain radio list and a diagonal maximize-2 trigger that
collided with the cashflow sankey's modal-expand button. Replace it with a
layout-config popover: a sliders-horizontal trigger plus two labeled axis
groups (Width, Height), each rendered as a DS::SegmentedControl with the
active option filled. Clearer, more compact, and it reads as a card-layout
control. The widget-size controller now mirrors the segmented control's
active-class + aria-pressed contract.

* feat(dashboard): expose width toggle on balance sheet and investments

Tables benefit from horizontal room, so give Balance Sheet and Investments
the same Width (Half/Full) control as the sankey and net worth chart. Height
presets stay chart-only — a table sized to a fixed height would just add
whitespace or force scrolling. The Outflows donut is intentionally left out
(full width is mostly whitespace for a donut).

* fix(dashboard): address review feedback on size controls

- Only apply the full-width col-span and show the Width control when the
  two-column layout is enabled. A full widget previously leaked
  2xl:col-span-2 into the single-column grid, creating an implicit second
  column at 2xl widths and breaking the single-column preference. This also
  keeps the Width control coherent with the Appearance two-column setting
  (it now appears only where it does something). [Codex]
- Stop size-menu keydowns from bubbling to the section reorder handler, so
  keyboard users can open the menu and pick options without entering
  grab/reorder mode. [Codex]
- Harden preferences params: ignore a malformed (non-hash)
  dashboard_section_layout / collapsed_sections instead of raising a 500,
  and require section_order to be an array. [CodeRabbit]
- Localize the dashboard sections aria-label. [CodeRabbit]

* chore(settings): mention per-widget size controls in two-column copy

Surface the new per-widget width/height controls in the Appearance
"Two-column layout" description so the capability is discoverable.

* test(dashboard): assert non-mutation for malformed layout input

Addresses review feedback: asserting assert_nil made the test depend on
the fixture happening to have no dashboard height for net_worth_chart.
Capture the pre-PATCH value and assert it is unchanged, so the test
stays valid (malformed input ignored) even if the fixture later gets a
default height.

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Guillem Arias <guillem.arias@col.vueling.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-06-15 20:34:34 +02:00

573 lines
16 KiB
Ruby

class User < ApplicationRecord
include Encryptable
# Allow nil password for SSO-only users (JIT provisioning).
# Custom validation ensures password is present for non-SSO registration.
has_secure_password validations: false
# Encrypt sensitive fields if ActiveRecord encryption is configured
if encryption_ready?
# MFA secrets
encrypts :otp_secret, deterministic: true
# PII - emails (deterministic for lookups, downcase for case-insensitive)
encrypts :email, deterministic: true, downcase: true
encrypts :unconfirmed_email, deterministic: true, downcase: true
# PII - names (non-deterministic for maximum security)
encrypts :first_name
encrypts :last_name
end
belongs_to :family
belongs_to :last_viewed_chat, class_name: "Chat", optional: true
belongs_to :default_account, class_name: "Account", optional: true
has_many :sessions, dependent: :destroy
has_many :chats, dependent: :destroy
has_many :api_keys, dependent: :destroy
has_many :webauthn_credentials, dependent: :destroy
has_many :mobile_devices, dependent: :destroy
has_many :invitations, foreign_key: :inviter_id, dependent: :destroy
has_many :impersonator_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonator_id, dependent: :destroy
has_many :impersonated_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonated_id, dependent: :destroy
has_many :oidc_identities, dependent: :destroy
has_many :sso_audit_logs, dependent: :nullify
has_many :owned_accounts, class_name: "Account", foreign_key: :owner_id
has_many :account_shares, dependent: :destroy
has_many :shared_accounts, through: :account_shares, source: :account
accepts_nested_attributes_for :family, update_only: true
MFA_BACKUP_CODE_COUNT = 8
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validate :ensure_valid_profile_image
validates :default_period, inclusion: { in: Period::PERIODS.keys }
validates :default_account_order, inclusion: { in: AccountOrder::ORDERS.keys }
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }, allow_nil: true
# Password is required on create unless the user is being created via SSO JIT.
# SSO JIT users have password_digest = nil and authenticate via OIDC only.
validates :password, presence: true, on: :create, unless: :skip_password_validation?
validates :password, length: { minimum: 8 }, allow_nil: true
normalizes :email, with: ->(email) { email.strip.downcase }
normalizes :unconfirmed_email, with: ->(email) { email&.strip&.downcase }
normalizes :locale, with: ->(locale) { locale.presence }
normalizes :first_name, :last_name, with: ->(value) { value.strip.presence }
enum :role, { guest: "guest", member: "member", admin: "admin", super_admin: "super_admin" }, validate: true
attribute :ui_layout, :string
enum :ui_layout, { dashboard: "dashboard", intro: "intro" }, validate: true, prefix: true
before_validation :apply_ui_layout_defaults
before_validation :apply_role_based_ui_defaults
# Returns the appropriate role for a new user creating a family.
# The very first user of an instance becomes super_admin; subsequent users
# get the specified fallback role (typically :admin for family creators).
def self.role_for_new_family_creator(fallback_role: :admin)
User.exists? ? fallback_role : :super_admin
end
has_one_attached :profile_image, dependent: :purge_later do |attachable|
attachable.variant :thumbnail, resize_to_fill: [ 300, 300 ], convert: :webp, saver: { quality: 80 }
attachable.variant :small, resize_to_fill: [ 72, 72 ], convert: :webp, saver: { quality: 80 }, preprocessed: true
end
validate :profile_image_size
generates_token_for :password_reset, expires_in: 15.minutes do
password_salt&.last(10)
end
generates_token_for :email_confirmation, expires_in: 1.day do
unconfirmed_email
end
def pending_email_change?
unconfirmed_email.present?
end
def initiate_email_change(new_email)
return false if new_email == email
if Rails.application.config.app_mode.self_hosted? && !Setting.require_email_confirmation
update(email: new_email)
else
if update(unconfirmed_email: new_email)
EmailConfirmationMailer.with(user: self).confirmation_email.deliver_later
true
else
false
end
end
end
def resend_confirmation_email
if pending_email_change?
EmailConfirmationMailer.with(user: self).confirmation_email.deliver_later
true
else
false
end
end
def request_impersonation_for(user_id)
impersonated = User.find(user_id)
impersonator_support_sessions.create!(impersonated: impersonated)
end
def admin?
super_admin? || role == "admin"
end
def accessible_accounts
family.accounts.accessible_by(self)
end
def finance_accounts
family.accounts.included_in_finances_for(self)
end
def display_name
[ first_name, last_name ].compact.join(" ").presence || email
end
def initial
(display_name&.first || email.first).upcase
end
def initials
if first_name.present? && last_name.present?
"#{first_name.first}#{last_name.first}".upcase
else
initial
end
end
def show_ai_sidebar?
show_ai_sidebar
end
def ai_available?
return true unless Rails.application.config.app_mode.self_hosted?
effective_type = ENV["ASSISTANT_TYPE"].presence || family&.assistant_type.presence || "builtin"
case effective_type
when "external"
Assistant::External.available_for?(self)
else
openai_configured? || anthropic_configured?
end
end
def openai_configured?
Provider::Openai.configured?
end
def anthropic_configured?
Provider::Anthropic.configured?
end
def ai_enabled?
ai_enabled && ai_available?
end
def self.default_ui_layout
layout = Rails.application.config.x.ui&.default_layout || "dashboard"
layout.in?(%w[intro dashboard]) ? layout : "dashboard"
end
# SSO-only users have OIDC identities but no local password.
# They cannot use password reset or local login.
def sso_only?
password_digest.nil? && oidc_identities.exists?
end
# Check if user has a local password set (can authenticate locally)
def has_local_password?
password_digest.present?
end
# Attribute to skip password validation during SSO JIT provisioning
attr_accessor :skip_password_validation
# Deactivation
validate :can_deactivate, if: -> { active_changed? && !active }
after_update_commit :purge_later, if: -> { saved_change_to_active?(from: true, to: false) }
def deactivate
update active: false, email: deactivated_email
end
def can_deactivate
if admin? && family.users.count > 1
errors.add(:base, :cannot_deactivate_admin_with_other_users)
end
end
def purge_later
UserPurgeJob.perform_later(self)
end
def purge
if last_user_in_family?
family.destroy
else
reassign_owned_accounts!
destroy
end
end
# MFA
def setup_mfa!
update!(
otp_secret: ROTP::Base32.random(32),
otp_required: false,
otp_backup_codes: []
)
end
def enable_mfa!
raise ArgumentError, "OTP secret must be set before enabling MFA" if otp_secret.blank?
backup_codes = generate_backup_codes
# Store bcrypt digests only; this Postgres array cannot use AR encryption.
update!(
otp_required: true,
otp_backup_codes: backup_codes.map { |code| digest_backup_code(code) }
)
backup_codes
end
def disable_mfa!
transaction do
update!(
otp_secret: nil,
otp_required: false,
otp_backup_codes: []
)
webauthn_credentials.destroy_all
end
end
def verify_otp?(code)
return false if otp_secret.blank?
normalized_code = normalize_mfa_code(code)
return false if normalized_code.blank?
return true if totp.verify(normalized_code, drift_behind: 15)
return false unless backup_code_input?(normalized_code)
consume_backup_code!(normalized_code)
end
def provisioning_uri
return nil unless otp_secret.present?
totp.provisioning_uri(email)
end
def ensure_webauthn_id!
return webauthn_id if webauthn_id.present?
with_lock do
update!(webauthn_id: WebAuthn.generate_user_id) unless webauthn_id.present?
end
webauthn_id
end
def webauthn_enabled?
otp_required? && webauthn_credentials.exists?
end
def onboarded?
onboarded_at.present?
end
def needs_onboarding?
!onboarded?
end
def account_order
AccountOrder.find(default_account_order) || AccountOrder.default
end
def default_account_for_transactions
return nil unless default_account_id.present?
account = default_account
return nil unless account&.eligible_for_transaction_default? && account.family_id == family_id
account
end
# Dashboard preferences management
def dashboard_section_collapsed?(section_key)
preferences&.dig("collapsed_sections", section_key) == true
end
def dashboard_section_order
preferences&.[]("section_order") || default_dashboard_section_order
end
# Per-widget height preset override ("compact" | "auto" | "tall"); nil = use default.
def dashboard_section_height(section_key)
preferences&.dig("dashboard_section_layout", section_key, "height")
end
# Per-widget column-span override ("single" | "full"); nil = use default.
def dashboard_section_width(section_key)
preferences&.dig("dashboard_section_layout", section_key, "col_span")
end
def update_dashboard_preferences(prefs)
# Use pessimistic locking to ensure atomic read-modify-write
# This prevents race conditions when multiple sections are collapsed quickly
transaction do
lock! # Acquire row-level lock (SELECT FOR UPDATE)
updated_prefs = (preferences || {}).deep_dup
prefs.each do |key, value|
if value.is_a?(Hash)
updated_prefs[key] ||= {}
# deep_merge so a partial update of one nested dimension (e.g. a widget's
# col_span) doesn't clobber a sibling dimension (e.g. its height).
updated_prefs[key] = updated_prefs[key].deep_merge(value)
else
updated_prefs[key] = value
end
end
update!(preferences: updated_prefs)
end
end
# Reports preferences management
def reports_section_collapsed?(section_key)
preferences&.dig("reports_collapsed_sections", section_key) == true
end
def reports_section_order
preferences&.[]("reports_section_order") || default_reports_section_order
end
def update_reports_preferences(prefs)
# Use pessimistic locking to ensure atomic read-modify-write
transaction do
lock!
updated_prefs = (preferences || {}).deep_dup
prefs.each do |key, value|
if value.is_a?(Hash)
updated_prefs[key] ||= {}
updated_prefs[key] = updated_prefs[key].merge(value)
else
updated_prefs[key] = value
end
end
update!(preferences: updated_prefs)
end
end
# Transactions preferences management
def transactions_section_collapsed?(section_key)
preferences&.dig("transactions_collapsed_sections", section_key) == true
end
def show_split_grouped?
preferences&.dig("show_split_grouped") != false
end
def dashboard_two_column?
preferences&.dig("dashboard_two_column") == true
end
def preview_features_enabled?
preferences&.dig("preview_features_enabled") == true
end
def update_transactions_preferences(prefs)
transaction do
lock!
updated_prefs = (preferences || {}).deep_dup
prefs.each do |key, value|
if value.is_a?(Hash)
updated_prefs["transactions_#{key}"] ||= {}
updated_prefs["transactions_#{key}"] = updated_prefs["transactions_#{key}"].merge(value)
else
updated_prefs["transactions_#{key}"] = value
end
end
update!(preferences: updated_prefs)
end
end
private
def apply_ui_layout_defaults
self.ui_layout = (ui_layout.presence || self.class.default_ui_layout)
end
def apply_role_based_ui_defaults
if ui_layout_intro?
if guest?
self.show_sidebar = false
self.show_ai_sidebar = false
self.ai_enabled = true
else
self.ui_layout = "dashboard"
end
elsif guest?
self.ui_layout = "intro"
self.show_sidebar = false
self.show_ai_sidebar = false
self.ai_enabled = true
end
if leaving_guest_role?
self.show_sidebar = true unless show_sidebar
self.show_ai_sidebar = true unless show_ai_sidebar
end
if new_record? && member? && !ai_available?
self.show_ai_sidebar = false
end
end
def leaving_guest_role?
return false unless will_save_change_to_role?
previous_role, new_role = role_change_to_be_saved
previous_role == "guest" && new_role != "guest"
end
def skip_password_validation?
skip_password_validation == true
end
def default_dashboard_section_order
%w[cashflow_sankey outflows_donut net_worth_chart balance_sheet]
end
def default_reports_section_order
%w[trends_insights transactions_breakdown]
end
def ensure_valid_profile_image
return unless profile_image.attached?
unless profile_image.content_type.in?(%w[image/jpeg image/png])
errors.add(:profile_image, "must be a JPEG or PNG")
profile_image.purge
end
end
def last_user_in_family?
family.users.count == 1
end
def reassign_owned_accounts!
account_ids = owned_accounts.pluck(:id)
return if account_ids.empty?
new_owner = family.users.where.not(id: id)
.find_by(role: %w[admin super_admin]) ||
family.users.where.not(id: id)
.order(:created_at).first
return unless new_owner
Account.where(id: account_ids).update_all(owner_id: new_owner.id)
# Remove shares the new owner had for these accounts (they now own them)
AccountShare.where(account_id: account_ids, user_id: new_owner.id).delete_all
end
def deactivated_email
email.gsub(/@/, "-deactivated-#{SecureRandom.uuid}@")
end
def profile_image_size
if profile_image.attached? && profile_image.byte_size > 10.megabytes
errors.add(:profile_image, :invalid_file_size, max_megabytes: 10)
end
end
def totp
ROTP::TOTP.new(otp_secret, issuer: "Sure Finances")
end
def consume_backup_code!(normalized_code)
consumed = false
transaction do
lock!
if otp_backup_codes.present?
matching_index = otp_backup_codes.index do |stored_code|
backup_code_matches?(stored_code, normalized_code)
end
if matching_index
remaining_codes = otp_backup_codes.dup
remaining_codes.delete_at(matching_index)
update!(otp_backup_codes: remaining_codes)
consumed = true
end
end
end
consumed
end
def generate_backup_codes
MFA_BACKUP_CODE_COUNT.times.map { SecureRandom.hex(8) }
end
def digest_backup_code(code)
BCrypt::Password.create(normalize_mfa_code(code), cost: backup_code_digest_cost).to_s
end
def backup_code_matches?(stored_code, normalized_code)
if backup_code_digest?(stored_code)
return false unless backup_code_input?(normalized_code)
BCrypt::Password.new(stored_code).is_password?(normalized_code)
else
# Legacy plaintext codes are accepted once so existing MFA users are
# not locked out after backup-code hashing ships.
ActiveSupport::SecurityUtils.secure_compare(stored_code.to_s, normalized_code)
end
rescue BCrypt::Errors::InvalidHash
false
end
def backup_code_digest?(stored_code)
stored_code.to_s.start_with?("$2a$", "$2b$", "$2y$")
end
def normalize_mfa_code(code)
code.to_s.strip.downcase
end
def backup_code_input?(code)
backup_code_candidate?(code) || legacy_plaintext_backup_code_candidate?(code)
end
def backup_code_candidate?(code)
code.to_s.match?(/\A[0-9a-f]{16}\z/)
end
def legacy_plaintext_backup_code_candidate?(code)
code.to_s.match?(/\A[0-9a-f]{8}\z/)
end
def backup_code_digest_cost
ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
end
end