diff --git a/app/controllers/snaptrade_items_controller.rb b/app/controllers/snaptrade_items_controller.rb index bcc09e49a..fa4fb054d 100644 --- a/app/controllers/snaptrade_items_controller.rb +++ b/app/controllers/snaptrade_items_controller.rb @@ -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) diff --git a/app/javascript/controllers/lazy_load_controller.js b/app/javascript/controllers/lazy_load_controller.js new file mode 100644 index 000000000..f210abfa6 --- /dev/null +++ b/app/javascript/controllers/lazy_load_controller.js @@ -0,0 +1,64 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="lazy-load" +// Used with
elements to lazy-load content when expanded +// Use data-action="toggle->lazy-load#toggled" on the
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 = `

${message}

`; + } + } +} diff --git a/app/models/snaptrade_item/provided.rb b/app/models/snaptrade_item/provided.rb index 952a496d3..5ffb43906 100644 --- a/app/models/snaptrade_item/provided.rb +++ b/app/models/snaptrade_item/provided.rb @@ -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 diff --git a/app/views/settings/providers/_snaptrade_panel.html.erb b/app/views/settings/providers/_snaptrade_panel.html.erb index 5b74295ca..572cd5d28 100644 --- a/app/views/settings/providers/_snaptrade_panel.html.erb +++ b/app/views/settings/providers/_snaptrade_panel.html.erb @@ -59,13 +59,9 @@ <% if item.user_registered? %>

- <% if item.snaptrade_accounts.any? %> - <%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %> - <% if item.unlinked_accounts_count > 0 %> - (<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>) - <% end %> - <% else %> - <%= t("providers.snaptrade.status_ready") %> + <%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %> + <% if item.unlinked_accounts_count > 0 %> + (<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>) <% end %>

<% else %> @@ -73,20 +69,33 @@

<%= t("providers.snaptrade.status_needs_registration") %>

<% end %> - - <% if item.snaptrade_accounts.any? %> -
- <%= link_to t("providers.snaptrade.setup_accounts_button"), - setup_accounts_snaptrade_item_path(item), - class: "btn btn--secondary btn--sm" %> -
- <% end %> - <% if item.snaptrade_accounts.any? %> -
-

<%= t("providers.snaptrade.connected_brokerages") %> <%= item.brokerage_summary %>

-
+ <% if item.user_registered? %> +
+ + <%= t("providers.snaptrade.manage_connections") %> + <%= icon "chevron-right", class: "w-3 h-3 transition-transform group-open:rotate-90" %> + + +
+

+ <%= t("providers.snaptrade.connection_limit_info") %> +

+ + <%# Loading state - replaced by fetched content %> +
+ <%= icon "loader-2", class: "w-4 h-4 animate-spin" %> + <%= t("providers.snaptrade.loading_connections") %> +
+ +
+
+
+
<% end %> <% else %>
diff --git a/app/views/snaptrade_items/_connections_list.html.erb b/app/views/snaptrade_items/_connections_list.html.erb new file mode 100644 index 000000000..e747ec33d --- /dev/null +++ b/app/views/snaptrade_items/_connections_list.html.erb @@ -0,0 +1,111 @@ +<%# locals: (connections:, orphaned_users: [], snaptrade_item:, error: nil) %> + +<% if error.present? %> +
+ <%= t("providers.snaptrade.connections_error", message: error) %> +
+<% elsif connections.empty? && orphaned_users.empty? %> +

+ <%= t("providers.snaptrade.no_connections") %> +

+<% else %> +
+ <%# Current user's connections %> + <% connections.each do |connection| %> +
"> +
+
+

"> + <%= connection[:brokerage_name] %> +

+

+ <% if connection[:orphaned_connection] %> + <%= t("providers.snaptrade.orphaned_connection") %> + <% else %> + <%= t("providers.snaptrade.accounts_count", count: connection[:accounts].size) %> + <% end %> +

+
+ + <%= 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 %> +
+ + <% unless connection[:orphaned_connection] || connection[:accounts].empty? %> +
    + <% connection[:accounts].each do |account| %> +
  • + "> + <%= account[:name] %> + <% unless account[:linked] %> + (<%= t("providers.snaptrade.needs_linking") %>) + <% end %> +
  • + <% end %> +
+ <% end %> +
+ <% end %> + + <%# Orphaned users (from previous registrations) %> + <% if orphaned_users.any? %> +
+

+ <%= icon "alert-triangle", size: "xs", class: "inline-block mr-1" %> + <%= t("providers.snaptrade.orphaned_users_title", count: orphaned_users.size) %> +

+

+ <%= t("providers.snaptrade.orphaned_users_description") %> +

+ + <% orphaned_users.each do |orphan| %> +
+
+
+

+ <%= t("providers.snaptrade.orphaned_user") %> +

+

+ <%= orphan[:display_name] %> +

+
+ + <%= 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 %> +
+
+ <% end %> +
+ <% end %> +
+<% end %> diff --git a/config/locales/views/snaptrade_items/en.yml b/config/locales/views/snaptrade_items/en.yml index a7132330c..44fc65721 100644 --- a/config/locales/views/snaptrade_items/en.yml +++ b/config/locales/views/snaptrade_items/en.yml @@ -1,5 +1,6 @@ en: snaptrade_items: + default_name: "SnapTrade Connection" create: success: "Successfully configured SnapTrade." update: @@ -39,6 +40,16 @@ en: success: "Successfully linked to SnapTrade account." failed: "Failed to link account: %{message}" not_found: "Account not found." + connections: + unknown_brokerage: "Unknown Brokerage" + delete_connection: + success: "Connection deleted successfully. One slot freed up." + failed: "Failed to delete connection: %{message}" + missing_authorization_id: "Missing authorization ID" + api_deletion_failed: "Could not delete connection from SnapTrade - missing credentials. The connection may still exist in your SnapTrade account." + delete_orphaned_user: + success: "Orphaned registration deleted successfully." + failed: "Failed to delete orphaned registration." setup_accounts: title: "Set Up SnapTrade Accounts" header: "Set Up Your SnapTrade Accounts" @@ -117,6 +128,29 @@ en: setup_accounts_button: "Setup Accounts" connect_button: "Connect Brokerage" connected_brokerages: "Connected:" + manage_connections: "Manage Connections" + connection_limit_info: "SnapTrade free tier allows 5 brokerage connections. Delete unused connections to free up slots." + loading_connections: "Loading connections..." + connections_error: "Failed to load connections: %{message}" + accounts_count: + one: "%{count} account" + other: "%{count} accounts" + orphaned_connection: "Orphaned connection (not synced locally)" + needs_linking: "needs linking" + no_connections: "No brokerage connections found." + delete_connection: "Delete" + delete_connection_title: "Delete Brokerage Connection?" + delete_connection_body: "This will permanently remove the %{brokerage} connection from SnapTrade. All accounts from this brokerage will be unlinked. You'll need to reconnect to sync these accounts again." + delete_connection_confirm: "Delete Connection" + orphaned_users_title: + one: "%{count} orphaned registration" + other: "%{count} orphaned registrations" + orphaned_users_description: "These are previous SnapTrade user registrations that are using your connection slots. Delete them to free up slots." + orphaned_user: "Orphaned Registration" + delete_orphaned_user: "Delete" + delete_orphaned_user_title: "Delete Orphaned Registration?" + delete_orphaned_user_body: "This will permanently delete this orphaned SnapTrade user and all their brokerage connections, freeing up connection slots." + delete_orphaned_user_confirm: "Delete Registration" snaptrade_item: sync_status: diff --git a/config/routes.rb b/config/routes.rb index 261adcff1..ffd81b372 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -49,6 +49,9 @@ Rails.application.routes.draw do get :connect get :setup_accounts post :complete_account_setup + get :connections + delete :delete_connection + delete :delete_orphaned_user end end