Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
391364d
feat(settings/providers): surface connection status in section headers
claude May 8, 2026
87ff9c0
feat(settings/providers): group providers into Connected and Available
jjmata May 8, 2026
6633f29
feat(settings/providers): health strip, action-needed group, and sync…
jjmata May 8, 2026
4623bc3
feat(settings/providers): card grid for available providers with conn…
jjmata May 8, 2026
2b59dd6
feat(settings): retire /settings/bank_sync; merge into providers page
jjmata May 8, 2026
a235b5a
Merge branch 'main' into feat/surface-provider-status
jjmata May 8, 2026
7e2f47d
Migrations are 7.2 here
jjmata May 8, 2026
423b209
Minimize schema noise
jjmata May 8, 2026
db7080d
Schema duplication
jjmata May 8, 2026
45b5bc0
Small copy edits
jjmata May 8, 2026
3851543
Fix tests
jjmata May 8, 2026
fff5c97
Merge remote-tracking branch 'origin/main' into feat/surface-provider…
jjmata May 9, 2026
60b2f2b
Address provider settings review feedback
jjmata May 9, 2026
bf73e3a
refactor(settings/providers): finish design-review cleanup pass
gariasf May 9, 2026
d037412
feat(settings/providers): replace Add another provider CTA with a sea…
gariasf May 9, 2026
4899d98
Private method fix
jjmata May 9, 2026
89726e6
refactor(settings/providers): drawer cleanup, header lock-up, trust s…
gariasf May 9, 2026
4bd9ddd
chore(locales): drop unused provider-panel status strings
gariasf May 9, 2026
0002237
feat(settings/providers): connected-state polish per design §05 + Lin…
gariasf May 9, 2026
b019944
fix(settings/providers): align connected state with the final design …
gariasf May 9, 2026
e0aab86
refactor(settings/providers): tighten paddings, dedupe maturity badge…
gariasf May 9, 2026
313ab83
fix(settings/providers): icons + search input height
gariasf May 9, 2026
693e3e4
refactor(settings/providers): align Sync all + search input with DS, …
gariasf May 9, 2026
229f657
fix(settings/providers): drawer trust statement uses border-tertiary
gariasf May 9, 2026
8a2f16a
refactor(settings/providers): swap arbitrary Tailwind values for scal…
gariasf May 9, 2026
41cd641
revert(settings/providers): drop the slim health strip
gariasf May 9, 2026
ee798ae
refactor(settings/providers): align with DS conventions
gariasf May 9, 2026
77a100b
fix(provider/metadata): add plaid_eu entry
gariasf May 9, 2026
e57f7ce
Merge branch 'main' into feat/surface-provider-status
gariasf May 9, 2026
8c96195
fix(settings/providers): center-align Sync all next to the lede
gariasf May 9, 2026
6abceb0
fix(settings/providers): drop colour palette + filter polish + drawer…
gariasf May 9, 2026
4c9e0f2
refactor(settings/providers): drawer alerts use DS::Alert; drop card-…
gariasf May 9, 2026
1c49750
fix(settings/providers): hoist warning alerts to top of drawer
gariasf May 9, 2026
c90836e
fix(DS::Alert): align icon to cap-height of first text line
gariasf May 9, 2026
3f3e717
copy(settings/providers): tighten alert messaging per voice review
gariasf May 9, 2026
81dd431
feat(settings/providers): SetupSteps partial for connect-drawer instr…
gariasf May 9, 2026
c5668cb
fix(settings/providers): tighten panel spacing + relocate per-panel n…
gariasf May 9, 2026
4b9ca7d
fix(settings/providers): plaid + indexa drawers join the SetupSteps look
gariasf May 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/components/settings/health_summary.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<div class="grid grid-cols-4 gap-2">
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
<% tiles.each do |tile| %>
<div class="bg-container shadow-border-xs rounded-xl p-3 text-center">
<div class="text-2xl font-bold tabular-nums <%= tile[:color_class] %>"><%= tile[:count] %></div>
<div class="text-xs text-secondary mt-0.5"><%= tile[:label] %></div>
</div>
<% end %>
</div>
38 changes: 38 additions & 0 deletions app/components/settings/health_summary.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
class Settings::HealthSummary < ApplicationComponent
def initialize(counts:)
@counts = counts
end

private
attr_reader :counts

def connected_count = counts[:connected]
def needs_attention_count = counts[:needs_attention]
def errors_count = counts[:errors]
def accounts_synced_count = counts[:accounts_synced]

def tiles
[
{
count: connected_count,
label: t("settings.providers.health.connected"),
color_class: connected_count > 0 ? "text-success" : "text-subdued"
},
{
count: needs_attention_count,
label: t("settings.providers.health.needs_attention"),
color_class: needs_attention_count > 0 ? "text-warning" : "text-subdued"
},
{
count: errors_count,
label: t("settings.providers.health.errors"),
color_class: errors_count > 0 ? "text-destructive" : "text-subdued"
},
{
count: accounts_synced_count,
label: t("settings.providers.health.accounts_synced"),
color_class: "text-primary"
}
]
end
end
29 changes: 29 additions & 0 deletions app/components/settings/provider_card.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<div class="bg-container shadow-border-xs rounded-xl p-4 flex flex-col gap-3 hover:shadow-border-sm transition-shadow">
<div class="flex items-start gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 <%= logo_bg %>">
<span class="text-xs font-bold text-white"><%= logo_text %></span>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-medium text-primary"><%= name %></span>
<% if maturity_label %>
<span class="text-xs font-medium px-1.5 py-0.5 rounded-full bg-alpha-black-50 text-secondary"><%= maturity_label %></span>
<% end %>
</div>
<% if meta_line.present? %>
<p class="text-xs text-subdued mt-0.5"><%= meta_line %></p>
<% end %>
</div>
</div>
<% if tagline.present? %>
<p class="text-sm text-secondary grow"><%= tagline %></p>
<% end %>
<div class="flex justify-end">
<%= link_to connect_path,
class: "inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:text-primary/70 transition-colors",
data: { turbo_frame: "drawer", turbo_prefetch: "false" } do %>
<%= t("settings.providers.connect") %>
<%= helpers.icon "arrow-right", class: "w-4 h-4" %>
<% end %>
</div>
</div>
31 changes: 31 additions & 0 deletions app/components/settings/provider_card.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
class Settings::ProviderCard < ApplicationComponent
MATURITY_LABELS = { beta: "Beta", alpha: "Alpha" }.freeze

def initialize(provider_key:, name:, tagline: nil, region: nil, kind: nil, tier: nil,
maturity: :stable, logo_bg: "bg-gray-500", logo_text: nil)
@provider_key = provider_key
@name = name
@tagline = tagline
@region = region
@kind = kind
@tier = tier
@maturity = maturity.to_sym
@logo_bg = logo_bg
@logo_text = logo_text || name.first(2).upcase
end

def maturity_label
MATURITY_LABELS[@maturity]
end

def meta_line
[ @region, @kind, @tier ].compact.join(" · ")
end

def connect_path
helpers.connect_form_settings_providers_path(provider_key: @provider_key)
end

private
attr_reader :provider_key, :name, :tagline, :region, :kind, :tier, :maturity, :logo_bg, :logo_text
end
43 changes: 0 additions & 43 deletions app/controllers/settings/bank_sync_controller.rb

This file was deleted.

202 changes: 190 additions & 12 deletions app/controllers/settings/providers_controller.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
class Settings::ProvidersController < ApplicationController
layout "settings"
layout -> { turbo_frame_request? ? "turbo_rails/frame" : "settings" }

before_action :ensure_admin, only: [ :show, :update ]
before_action :ensure_admin, only: [ :show, :update, :sync_all, :sync, :connect_form ]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

show is now admin-gated, so the shared Bank Sync entry dead-ends for family members.

Including :show here means a non-admin who follows the new Bank Sync nav item gets bounced to root_path with "Not authorized" instead of seeing the page. If the page is meant to be reachable for both admin and non-admin users per the PR objective, keep the write/sync actions restricted but leave show accessible.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/controllers/settings/providers_controller.rb` at line 4, The
before_action currently gates :show behind ensure_admin (before_action
:ensure_admin, only: [ :show, :update, :sync_all, :sync, :connect_form ]), which
blocks non-admin family members from viewing the Bank Sync page; remove :show
from the only list so that ensure_admin continues to protect write/sync actions
(:update, :sync_all, :sync, :connect_form) but allows non-admins to reach the
show action.


def show
@breadcrumbs = [
[ "Home", root_path ],
[ "Bank Sync Providers", nil ]
[ "Bank sync", nil ]
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.

prepare_show_context
Expand Down Expand Up @@ -77,6 +77,54 @@ def update
render :show, status: :unprocessable_entity
end

def sync_all
family = Current.family

if family.last_sync_all_attempted_at.present? && family.last_sync_all_attempted_at > 30.seconds.ago
return redirect_to settings_providers_path, notice: t("settings.providers.sync_all_recently")
end

family.update!(last_sync_all_attempted_at: Time.current)
SyncAllProvidersJob.perform_later(family.id)
redirect_to settings_providers_path, notice: t("settings.providers.sync_all_in_progress")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
end

def sync
provider_key = params[:provider_key]
syncable_type = PANEL_SYNCABLE_TYPES[provider_key]
return redirect_to settings_providers_path unless syncable_type

items = syncable_type.constantize.where(family: Current.family).syncable
items.each { |item| item.sync_later unless item.syncing? }

redirect_to settings_providers_path, notice: t("settings.providers.sync_provider_in_progress")
end

def connect_form
provider_key = params[:provider_key]

panel = FAMILY_PANELS.find { |p| p[:key] == provider_key }
if panel
@panel_key = panel[:key]
@panel_partial = panel[:partial]
@panel_title = panel[:title]
load_provider_items(provider_key)
return render :connect_form
end

Provider::Factory.ensure_adapters_loaded
config = Provider::ConfigurationRegistry.all.find { |c| c.provider_key.to_s == provider_key }
if config
@panel_title = provider_key.titleize
@provider_configuration = config
return render :connect_form
end

redirect_to settings_providers_path
rescue ActiveRecord::Encryption::Errors::Configuration
redirect_to settings_providers_path, alert: t("settings.providers.encryption_error.title")
end

private
def provider_params
# Dynamically permit all provider configuration fields
Expand Down Expand Up @@ -119,19 +167,71 @@ def reload_provider_configs(updated_fields)
end
end

# Hardcoded family-scoped panels — provider connections are managed through
# their own models (SimplefinItem, LunchflowItem, etc.) rather than global
# settings, so they need custom UI per-provider for connection management,
# status display, and sync actions. The configuration registry excludes
# them (see prepare_show_context).
FAMILY_PANELS = [
{ key: "lunchflow", title: "Lunch Flow", turbo_id: "lunchflow", partial: "lunchflow_panel" },
{ key: "simplefin", title: "SimpleFIN", turbo_id: "simplefin", partial: "simplefin_panel" },
{ key: "enable_banking", title: "Enable Banking", turbo_id: "enable_banking", partial: "enable_banking_panel" },
{ key: "coinstats", title: "CoinStats", turbo_id: "coinstats", partial: "coinstats_panel" },
{ key: "mercury", title: "Mercury", turbo_id: "mercury", partial: "mercury_panel" },
{ key: "coinbase", title: "Coinbase", turbo_id: "coinbase", partial: "coinbase_panel" },
{ key: "binance", title: "Binance", turbo_id: "binance", partial: "binance_panel" },
{ key: "snaptrade", title: "SnapTrade", turbo_id: "snaptrade", partial: "snaptrade_panel", auto_open: "manage" },
{ key: "indexa_capital", title: "Indexa Capital", turbo_id: "indexa_capital", partial: "indexa_capital_panel" },
{ key: "sophtron", title: "Sophtron", turbo_id: "sophtron", partial: "sophtron_panel" }
].freeze

FAMILY_PANEL_KEYS = FAMILY_PANELS.map { |p| p[:key] }.freeze

# Maps panel key → ActiveRecord model name for sync health queries
PANEL_SYNCABLE_TYPES = {
"simplefin" => "SimplefinItem",
"lunchflow" => "LunchflowItem",
"enable_banking" => "EnableBankingItem",
"coinstats" => "CoinstatsItem",
"mercury" => "MercuryItem",
"coinbase" => "CoinbaseItem",
"binance" => "BinanceItem",
"snaptrade" => "SnaptradeItem",
"indexa_capital" => "IndexaCapitalItem",
"sophtron" => "SophtronItem"
}.freeze

def load_provider_items(provider_key)
case provider_key
when "simplefin"
@simplefin_items = Current.family.simplefin_items.ordered
when "lunchflow"
@lunchflow_items = Current.family.lunchflow_items.ordered
when "enable_banking"
@enable_banking_items = Current.family.enable_banking_items.ordered
when "coinstats"
@coinstats_items = Current.family.coinstats_items.ordered
when "mercury"
@mercury_items = Current.family.mercury_items.active.ordered.includes(:syncs, :mercury_accounts)
when "coinbase"
@coinbase_items = Current.family.coinbase_items.ordered
when "binance"
@binance_items = Current.family.binance_items.active.ordered
when "snaptrade"
@snaptrade_items = Current.family.snaptrade_items.includes(:snaptrade_accounts).ordered
when "indexa_capital"
@indexa_capital_items = Current.family.indexa_capital_items.ordered
when "sophtron"
@sophtron_items = Current.family.sophtron_items.ordered
end
end

# Prepares instance vars needed by the show view and partials
def prepare_show_context
# Load all provider configurations (exclude SimpleFin and Lunchflow, which have their own family-specific panels below)
# Load all provider configurations (exclude family-scoped panels, which have their own UI below)
Provider::Factory.ensure_adapters_loaded
@provider_configurations = Provider::ConfigurationRegistry.all.reject do |config|
config.provider_key.to_s.casecmp("simplefin").zero? || config.provider_key.to_s.casecmp("lunchflow").zero? || \
config.provider_key.to_s.casecmp("enable_banking").zero? || \
config.provider_key.to_s.casecmp("sophtron").zero? || \
config.provider_key.to_s.casecmp("coinstats").zero? || \
config.provider_key.to_s.casecmp("mercury").zero? || \
config.provider_key.to_s.casecmp("coinbase").zero? || \
config.provider_key.to_s.casecmp("snaptrade").zero? || \
config.provider_key.to_s.casecmp("indexa_capital").zero?
FAMILY_PANEL_KEYS.any? { |key| config.provider_key.to_s.casecmp(key).zero? }
end

# Providers page only needs to know whether any SimpleFin/Lunchflow connections exist with valid credentials
Expand All @@ -145,5 +245,83 @@ def prepare_show_context
@coinbase_items = Current.family.coinbase_items.ordered # Coinbase panel needs name and sync info for status display
@snaptrade_items = Current.family.snaptrade_items.includes(:snaptrade_accounts).ordered
@indexa_capital_items = Current.family.indexa_capital_items.ordered.select(:id)
@binance_items = Current.family.binance_items.active.ordered

@provider_sync_health = compute_provider_sync_health

entries = build_provider_entries

@connected = entries.select { |e| e[:summary][:status] == :ok }
@needs_attention = entries.select { |e| [ :warn, :err ].include?(e[:summary][:status]) }
@available = entries.select { |e| e[:summary][:status] == :off }

@health_counts = {
connected: @connected.size + @needs_attention.size,
needs_attention: @needs_attention.size,
errors: @needs_attention.count { |e| e[:summary][:status] == :err },
accounts_synced: Current.family.accounts.joins(:account_providers).distinct.count
}
end

# Returns a hash mapping provider key → { error:, last_synced_at:, stale: }
# by querying the latest sync per item for each family panel provider.
def compute_provider_sync_health
PANEL_SYNCABLE_TYPES.each_with_object({}) do |(key, syncable_type), health|
items = instance_variable_get("@#{key}_items")
ids = items&.map(&:id)&.compact
next if ids.blank?

health[key] = sync_health_for(syncable_type, ids)
end
end

# Determines error/stale status and last successful sync time for a set of items.
def sync_health_for(syncable_type, item_ids)
# Use window function to get the single latest sync per item (same pattern as ProviderConnectionStatus)
ranked_subq = Sync
.where(syncable_type: syncable_type, syncable_id: item_ids)
.select("syncs.*, ROW_NUMBER() OVER (PARTITION BY syncable_id ORDER BY created_at DESC, id DESC) AS sync_rank")

latest_per_item = Sync.from(ranked_subq, :syncs).where("sync_rank = 1").to_a

has_error = latest_per_item.any? { |s| s.failed? || s.stale? }

last_synced = Sync
.where(syncable_type: syncable_type, syncable_id: item_ids, status: "completed")
.maximum(:completed_at)

stale = !has_error && last_synced.present? && last_synced < 24.hours.ago

{ error: has_error, last_synced_at: last_synced, stale: stale }
end

# Builds a unified list of provider entries (registry-driven configurations
# and hardcoded family panels) with pre-computed status, sorted
# alphabetically by display title. Each entry carries enough data for the
# view to render either a provider_form or a family panel partial.
def build_provider_entries
configuration_entries = @provider_configurations.map do |config|
{
provider_key: config.provider_key.to_s,
title: config.provider_key.to_s.titleize,
configuration: config,
maturity: Provider::Metadata.for(config.provider_key)[:maturity],
summary: view_context.provider_summary(config.provider_key)
}
end

family_entries = FAMILY_PANELS.map do |panel|
{
provider_key: panel[:key],
title: panel[:title],
turbo_id: panel[:turbo_id],
partial: panel[:partial],
auto_open_param: panel[:auto_open],
maturity: Provider::Metadata.for(panel[:key])[:maturity],
summary: view_context.provider_summary(panel[:key])
}
end

(configuration_entries + family_entries).sort_by { |entry| entry[:title].downcase }
end
end
Loading