mirror of
https://github.com/we-promise/sure.git
synced 2026-04-20 04:24:06 +00:00
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:
@@ -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)
|
||||
|
||||
64
app/javascript/controllers/lazy_load_controller.js
Normal file
64
app/javascript/controllers/lazy_load_controller.js
Normal 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>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
111
app/views/snaptrade_items/_connections_list.html.erb
Normal file
111
app/views/snaptrade_items/_connections_list.html.erb
Normal 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 %>
|
||||
Reference in New Issue
Block a user