mirror of
https://github.com/we-promise/sure.git
synced 2026-07-12 12:55:20 +00:00
* 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>
573 lines
16 KiB
Ruby
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
|