Add SnapTrade connection management UI for freeing up connection slots (#747)

* Add SnapTrade connection management with lazy-loading and deletion functionality.

* Refactor lazy-load controller to simplify event handling and enhance loading state management; improve SnapTrade deletion logic with additional safeguards and logging.

* Improve SnapTrade connection error handling and centralize unknown brokerage message using i18n.

* Centralize SnapTrade connection default name and missing authorization ID messages using i18n.

* Enhance SnapTrade connection deletion logic with improved error handling, i18n support for API deletion failures, and consistent Turbo Stream responses.

---------

Co-authored-by: luckyPipewrench <luckypipewrench@proton.me>
This commit is contained in:
LPW
2026-01-23 02:55:34 -05:00
committed by GitHub
parent 3f5fff27ea
commit e6d8112278
7 changed files with 426 additions and 21 deletions

View File

@@ -1,5 +1,5 @@
class SnaptradeItemsController < ApplicationController
before_action :set_snaptrade_item, only: [ :show, :edit, :update, :destroy, :sync, :connect, :setup_accounts, :complete_account_setup ]
before_action :set_snaptrade_item, only: [ :show, :edit, :update, :destroy, :sync, :connect, :setup_accounts, :complete_account_setup, :connections, :delete_connection, :delete_orphaned_user ]
def index
@snaptrade_items = Current.family.snaptrade_items.ordered
@@ -17,7 +17,7 @@ class SnaptradeItemsController < ApplicationController
def create
@snaptrade_item = Current.family.snaptrade_items.build(snaptrade_item_params)
@snaptrade_item.name ||= "SnapTrade Connection"
@snaptrade_item.name ||= t("snaptrade_items.default_name")
if @snaptrade_item.save
# Register user with SnapTrade after saving credentials
@@ -235,6 +235,104 @@ class SnaptradeItemsController < ApplicationController
end
end
# Fetch connections list for Turbo Frame
def connections
data = build_connections_list
render partial: "snaptrade_items/connections_list", layout: false, locals: {
connections: data[:connections],
orphaned_users: data[:orphaned_users],
snaptrade_item: @snaptrade_item,
error: @error
}
end
# Delete a brokerage connection
def delete_connection
authorization_id = params[:authorization_id]
if authorization_id.blank?
redirect_to settings_providers_path, alert: t(".failed", message: t(".missing_authorization_id"))
return
end
# Delete all local SnaptradeAccounts for this connection (triggers cleanup job)
accounts_deleted = @snaptrade_item.snaptrade_accounts
.where(snaptrade_authorization_id: authorization_id)
.destroy_all
.size
# If no local accounts existed (orphan), delete directly from API
api_deletion_failed = false
if accounts_deleted == 0
provider = @snaptrade_item.snaptrade_provider
creds = @snaptrade_item.snaptrade_credentials
if provider && creds&.dig(:user_id) && creds&.dig(:user_secret)
provider.delete_connection(
user_id: creds[:user_id],
user_secret: creds[:user_secret],
authorization_id: authorization_id
)
else
Rails.logger.warn "SnapTrade: Cannot delete orphaned connection #{authorization_id} - missing credentials"
api_deletion_failed = true
end
end
respond_to do |format|
if api_deletion_failed
format.html { redirect_to settings_providers_path, alert: t(".api_deletion_failed") }
format.turbo_stream do
flash.now[:alert] = t(".api_deletion_failed")
render turbo_stream: flash_notification_stream_items
end
else
format.html { redirect_to settings_providers_path, notice: t(".success") }
format.turbo_stream { render turbo_stream: turbo_stream.remove("connection_#{authorization_id}") }
end
end
rescue Provider::Snaptrade::ApiError => e
respond_to do |format|
format.html { redirect_to settings_providers_path, alert: t(".failed", message: e.message) }
format.turbo_stream do
flash.now[:alert] = t(".failed", message: e.message)
render turbo_stream: flash_notification_stream_items
end
end
end
# Delete an orphaned SnapTrade user (and all their connections)
def delete_orphaned_user
user_id = params[:user_id]
# Security: verify this is actually an orphaned user
unless @snaptrade_item.orphaned_users.include?(user_id)
respond_to do |format|
format.html { redirect_to settings_providers_path, alert: t(".failed") }
format.turbo_stream do
flash.now[:alert] = t(".failed")
render turbo_stream: flash_notification_stream_items
end
end
return
end
if @snaptrade_item.delete_orphaned_user(user_id)
respond_to do |format|
format.html { redirect_to settings_providers_path, notice: t(".success") }
format.turbo_stream { render turbo_stream: turbo_stream.remove("orphaned_user_#{user_id.parameterize}") }
end
else
respond_to do |format|
format.html { redirect_to settings_providers_path, alert: t(".failed") }
format.turbo_stream do
flash.now[:alert] = t(".failed")
render turbo_stream: flash_notification_stream_items
end
end
end
end
# Collection actions for account linking flow
def preload_accounts
@@ -323,6 +421,49 @@ class SnaptradeItemsController < ApplicationController
)
end
def build_connections_list
# Fetch connections for current user from API
api_connections = @snaptrade_item.fetch_connections
# Get local accounts grouped by authorization_id
local_accounts = @snaptrade_item.snaptrade_accounts
.includes(:account_provider)
.group_by(&:snaptrade_authorization_id)
# Build unified list
result = { connections: [], orphaned_users: [] }
# Add connections from API for current user
api_connections.each do |api_conn|
auth_id = api_conn.id
local_accts = local_accounts[auth_id] || []
result[:connections] << {
authorization_id: auth_id,
brokerage_name: api_conn.brokerage&.name || I18n.t("snaptrade_items.connections.unknown_brokerage"),
brokerage_slug: api_conn.brokerage&.slug,
accounts: local_accts.map { |acct|
{ id: acct.id, name: acct.name, linked: acct.account_provider.present? }
},
orphaned_connection: local_accts.empty?
}
end
# Add orphaned users (users registered but not current)
orphaned = @snaptrade_item.orphaned_users
orphaned.each do |user_id|
result[:orphaned_users] << {
user_id: user_id,
display_name: user_id.truncate(30)
}
end
result
rescue Provider::Snaptrade::ApiError => e
@error = e.message
{ connections: [], orphaned_users: [] }
end
def link_snaptrade_account(snaptrade_account)
# Determine account type based on SnapTrade account type
accountable_type = infer_accountable_type(snaptrade_account.account_type)

View File

@@ -0,0 +1,64 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="lazy-load"
// Used with <details> elements to lazy-load content when expanded
// Use data-action="toggle->lazy-load#toggled" on the <details> element
export default class extends Controller {
static targets = ["content", "loading", "frame"];
static values = { url: String, loaded: Boolean };
connect() {
// If already open on connect (browser restored state), load immediately
if (this.element.open && !this.loadedValue) {
this.load();
}
}
toggled() {
if (this.element.open && !this.loadedValue) {
this.load();
}
}
async load() {
if (this.loadedValue || this.loading) return;
this.loading = true;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
const response = await fetch(this.urlValue, {
headers: {
Accept: "text/html",
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-Token": csrfToken,
},
credentials: "same-origin",
});
if (response.ok) {
const html = await response.text();
if (this.hasFrameTarget) {
this.frameTarget.innerHTML = html;
}
if (this.hasLoadingTarget) {
this.loadingTarget.classList.add("hidden");
}
this.loadedValue = true;
} else {
console.error("Lazy load failed:", response.status, response.statusText);
this.showError(`Failed to load (${response.status})`);
}
} catch (error) {
console.error("Lazy load error:", error);
this.showError("Network error");
} finally {
this.loading = false;
}
}
showError(message) {
if (this.hasLoadingTarget) {
this.loadingTarget.innerHTML = `<p class="text-destructive text-sm">${message}</p>`;
}
}
}

View File

@@ -131,4 +131,47 @@ module SnaptradeItem::Provided
broker: broker
)
end
# Fetch all brokerage connections from SnapTrade API
# Returns array of connection objects
def fetch_connections
return [] unless credentials_configured? && user_registered?
provider = snaptrade_provider
creds = snaptrade_credentials
provider.list_connections(user_id: creds[:user_id], user_secret: creds[:user_secret])
rescue Provider::Snaptrade::ApiError => e
Rails.logger.error "SnaptradeItem #{id} - Failed to list connections: #{e.message}"
raise
end
# List all SnapTrade users registered under this client ID
def list_all_users
return [] unless credentials_configured?
snaptrade_provider.list_users
rescue Provider::Snaptrade::ApiError => e
Rails.logger.error "SnaptradeItem #{id} - Failed to list users: #{e.message}"
[]
end
# Find orphaned SnapTrade users (registered but not current user)
def orphaned_users
return [] unless credentials_configured? && user_registered?
all_users = list_all_users
all_users.reject { |uid| uid == snaptrade_user_id }
end
# Delete an orphaned SnapTrade user and all their connections
def delete_orphaned_user(user_id)
return false unless credentials_configured?
return false if user_id == snaptrade_user_id # Don't delete current user
snaptrade_provider.delete_user(user_id: user_id)
true
rescue Provider::Snaptrade::ApiError => e
Rails.logger.error "SnaptradeItem #{id} - Failed to delete orphaned user #{user_id}: #{e.message}"
false
end
end

View File

@@ -59,13 +59,9 @@
<% if item.user_registered? %>
<div class="w-2 h-2 bg-success rounded-full"></div>
<p class="text-sm text-secondary">
<% if item.snaptrade_accounts.any? %>
<%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %>
<% if item.unlinked_accounts_count > 0 %>
<span class="text-warning">(<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>)</span>
<% end %>
<% else %>
<%= t("providers.snaptrade.status_ready") %>
<%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %>
<% if item.unlinked_accounts_count > 0 %>
<span class="text-warning">(<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>)</span>
<% end %>
</p>
<% else %>
@@ -73,20 +69,33 @@
<p class="text-sm text-secondary"><%= t("providers.snaptrade.status_needs_registration") %></p>
<% end %>
</div>
<% if item.snaptrade_accounts.any? %>
<div class="flex items-center gap-2">
<%= link_to t("providers.snaptrade.setup_accounts_button"),
setup_accounts_snaptrade_item_path(item),
class: "btn btn--secondary btn--sm" %>
</div>
<% end %>
</div>
<% if item.snaptrade_accounts.any? %>
<div class="mt-3 text-sm text-secondary">
<p><%= t("providers.snaptrade.connected_brokerages") %> <%= item.brokerage_summary %></p>
</div>
<% if item.user_registered? %>
<details class="group mt-4"
data-controller="lazy-load"
data-action="toggle->lazy-load#toggled"
data-lazy-load-url-value="<%= connections_snaptrade_item_path(item) %>">
<summary class="flex items-center justify-end gap-1 cursor-pointer text-sm text-secondary hover:text-primary list-none [&::-webkit-details-marker]:hidden">
<%= t("providers.snaptrade.manage_connections") %>
<%= icon "chevron-right", class: "w-3 h-3 transition-transform group-open:rotate-90" %>
</summary>
<div class="mt-3 space-y-3" data-lazy-load-target="content">
<p class="text-xs text-secondary">
<%= t("providers.snaptrade.connection_limit_info") %>
</p>
<%# Loading state - replaced by fetched content %>
<div data-lazy-load-target="loading" class="flex items-center gap-2 text-sm text-secondary py-2">
<%= icon "loader-2", class: "w-4 h-4 animate-spin" %>
<%= t("providers.snaptrade.loading_connections") %>
</div>
<div data-lazy-load-target="frame">
</div>
</div>
</details>
<% end %>
<% else %>
<div class="flex items-center gap-2">

View File

@@ -0,0 +1,111 @@
<%# locals: (connections:, orphaned_users: [], snaptrade_item:, error: nil) %>
<% if error.present? %>
<div class="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
<%= t("providers.snaptrade.connections_error", message: error) %>
</div>
<% elsif connections.empty? && orphaned_users.empty? %>
<p class="text-sm text-secondary py-2">
<%= t("providers.snaptrade.no_connections") %>
</p>
<% else %>
<div class="space-y-3">
<%# Current user's connections %>
<% connections.each do |connection| %>
<div id="connection_<%= connection[:authorization_id] %>"
class="border rounded-lg p-3 <%= connection[:orphaned_connection] ? "border-warning/50 bg-warning/5" : "border-secondary" %>">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium <%= connection[:orphaned_connection] ? "text-warning" : "text-primary" %>">
<%= connection[:brokerage_name] %>
</p>
<p class="text-xs text-secondary">
<% if connection[:orphaned_connection] %>
<%= t("providers.snaptrade.orphaned_connection") %>
<% else %>
<%= t("providers.snaptrade.accounts_count", count: connection[:accounts].size) %>
<% end %>
</p>
</div>
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "button",
text: t("providers.snaptrade.delete_connection"),
icon: "trash-2",
href: delete_connection_snaptrade_item_path(snaptrade_item, authorization_id: connection[:authorization_id]),
method: :delete,
confirm: CustomConfirm.new(
title: t("providers.snaptrade.delete_connection_title"),
body: t("providers.snaptrade.delete_connection_body", brokerage: connection[:brokerage_name]),
btn_text: t("providers.snaptrade.delete_connection_confirm"),
destructive: true,
high_severity: true
)
) %>
<% end %>
</div>
<% unless connection[:orphaned_connection] || connection[:accounts].empty? %>
<ul class="mt-2 text-xs text-secondary space-y-1">
<% connection[:accounts].each do |account| %>
<li class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full <%= account[:linked] ? "bg-success" : "bg-warning" %>"></span>
<%= account[:name] %>
<% unless account[:linked] %>
<span class="text-warning">(<%= t("providers.snaptrade.needs_linking") %>)</span>
<% end %>
</li>
<% end %>
</ul>
<% end %>
</div>
<% end %>
<%# Orphaned users (from previous registrations) %>
<% if orphaned_users.any? %>
<div class="border-t border-secondary pt-3 mt-3">
<p class="text-xs text-warning font-medium mb-2">
<%= icon "alert-triangle", size: "xs", class: "inline-block mr-1" %>
<%= t("providers.snaptrade.orphaned_users_title", count: orphaned_users.size) %>
</p>
<p class="text-xs text-secondary mb-3">
<%= t("providers.snaptrade.orphaned_users_description") %>
</p>
<% orphaned_users.each do |orphan| %>
<div id="orphaned_user_<%= orphan[:user_id].parameterize %>"
class="border rounded-lg p-3 border-warning/50 bg-warning/5 mb-2">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-warning">
<%= t("providers.snaptrade.orphaned_user") %>
</p>
<p class="text-xs text-secondary font-mono truncate" title="<%= orphan[:user_id] %>">
<%= orphan[:display_name] %>
</p>
</div>
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "button",
text: t("providers.snaptrade.delete_orphaned_user"),
icon: "trash-2",
href: delete_orphaned_user_snaptrade_item_path(snaptrade_item, user_id: orphan[:user_id]),
method: :delete,
confirm: CustomConfirm.new(
title: t("providers.snaptrade.delete_orphaned_user_title"),
body: t("providers.snaptrade.delete_orphaned_user_body"),
btn_text: t("providers.snaptrade.delete_orphaned_user_confirm"),
destructive: true,
high_severity: true
)
) %>
<% end %>
</div>
</div>
<% end %>
</div>
<% end %>
</div>
<% end %>