diff --git a/app/components/DS/alert.html.erb b/app/components/DS/alert.html.erb index 8f5c0fd41..419a4607c 100644 --- a/app/components/DS/alert.html.erb +++ b/app/components/DS/alert.html.erb @@ -1,5 +1,5 @@
- <%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0" %> + <%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0 mt-0.5" %>
<% if title.present? %> diff --git a/app/components/settings/provider_card.html.erb b/app/components/settings/provider_card.html.erb new file mode 100644 index 000000000..6d0210a17 --- /dev/null +++ b/app/components/settings/provider_card.html.erb @@ -0,0 +1,25 @@ +<%= link_to connect_path, + class: "bg-container shadow-border-xs hover:bg-surface-inset rounded-xl p-4 flex flex-col gap-2.5 text-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300", + data: { turbo_frame: "drawer", turbo_prefetch: "false" }.merge(filter_data) do %> +
+
+ <%= logo_text %> +
+
+
+ <%= name %> + <%= render "settings/providers/maturity_badge", label: maturity_label %> +
+ <% if meta_line.present? %> +

<%= meta_line %>

+ <% end %> +
+
+ <% if tagline.present? %> +

<%= tagline %>

+ <% end %> +
+ <%= t("settings.providers.connect") %> + <%= helpers.icon "arrow-right", size: "sm", class: "!w-3.5 !h-3.5" %> +
+<% end %> diff --git a/app/components/settings/provider_card.rb b/app/components/settings/provider_card.rb new file mode 100644 index 000000000..c57657a2e --- /dev/null +++ b/app/components/settings/provider_card.rb @@ -0,0 +1,47 @@ +class Settings::ProviderCard < ApplicationComponent + MATURITY_LABELS = { + beta: "settings.providers.maturity.beta", + alpha: "settings.providers.maturity.alpha" + }.freeze + + def self.maturity_label(maturity) + key = MATURITY_LABELS[maturity&.to_sym] + I18n.t(key) if key + end + + 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 + + attr_reader :name, :tagline, :logo_bg, :logo_text + + def maturity_label + self.class.maturity_label(@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 + + def filter_data + { + providers_filter_target: "card", + provider_name: @name.to_s.downcase, + provider_region: @region.to_s.downcase, + provider_kind: @kind.to_s.downcase + } + end +end diff --git a/app/controllers/settings/bank_sync_controller.rb b/app/controllers/settings/bank_sync_controller.rb deleted file mode 100644 index 91e55a3ef..000000000 --- a/app/controllers/settings/bank_sync_controller.rb +++ /dev/null @@ -1,43 +0,0 @@ -class Settings::BankSyncController < ApplicationController - layout "settings" - - def show - @providers = [ - { - name: "Lunch Flow", - description: "US, Canada, UK, EU, Brazil and Asia through multiple open banking providers.", - path: "https://lunchflow.app/features/sure-integration?atp=BiDIYS", - target: "_blank", - rel: "noopener noreferrer" - }, - { - name: "Plaid", - description: "US & Canada bank connections with transactions, investments, and liabilities.", - path: "https://github.com/we-promise/sure/blob/main/docs/hosting/plaid.md", - target: "_blank", - rel: "noopener noreferrer" - }, - { - name: "SimpleFIN", - description: "US & Canada connections via SimpleFIN protocol.", - path: "https://beta-bridge.simplefin.org", - target: "_blank", - rel: "noopener noreferrer" - }, - { - name: "Enable Banking (beta)", - description: "European bank connections via open banking APIs across multiple countries.", - path: "https://enablebanking.com", - target: "_blank", - rel: "noopener noreferrer" - }, - { - name: "Sophtron (alpha)", - description: "US & Canada bank, credit card, investment, loan, insurance, utility, and other connections.", - path: "https://www.sophtron.com/", - target: "_blank", - rel: "noopener noreferrer" - } - ] - end -end diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index 7c6428376..361097ae1 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -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 ] def show @breadcrumbs = [ [ "Home", root_path ], - [ "Bank Sync Providers", nil ] + [ "Bank sync", nil ] ] prepare_show_context @@ -77,6 +77,61 @@ class Settings::ProvidersController < ApplicationController render :show, status: :unprocessable_entity end + def sync_all + family = Current.family + now = Time.current + + updated_count = Family + .where(id: family.id) + .where("last_sync_all_attempted_at IS NULL OR last_sync_all_attempted_at <= ?", 30.seconds.ago) + .update_all(last_sync_all_attempted_at: now, updated_at: now) + + if updated_count.zero? + return redirect_to settings_providers_path, notice: t("settings.providers.sync_all_recently") + end + + SyncAllProvidersJob.perform_later(family.id) + redirect_to settings_providers_path, notice: t("settings.providers.sync_all_in_progress") + 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 + scheduled = items.reject(&:syncing?) + scheduled.each(&:sync_later) + + notice_key = scheduled.any? ? "settings.providers.sync_provider_in_progress" : "settings.providers.sync_provider_no_items" + redirect_to settings_providers_path, notice: t(notice_key) + 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::Metadata.for(provider_key)[:name] || provider_key.titleize + @provider_configuration = config + return render :connect_form + end + + redirect_to settings_providers_path, alert: t("settings.providers.not_found") + 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 @@ -93,7 +148,9 @@ class Settings::ProvidersController < ApplicationController end def ensure_admin - redirect_to settings_providers_path, alert: "Not authorized" unless Current.user.admin? + return if Current.user.admin? + + redirect_to root_path, alert: t("settings.providers.not_authorized") end # Reload provider configurations after settings update @@ -119,19 +176,71 @@ class Settings::ProvidersController < ApplicationController 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 @@ -141,9 +250,100 @@ class Settings::ProvidersController < ApplicationController # Providers page only needs to know whether any Sophtron connections exist with valid credentials @sophtron_items = Current.family.sophtron_items.where.not(user_id: [ nil, "" ], access_key: [ nil, "" ]).ordered.select(:id) @coinstats_items = Current.family.coinstats_items.ordered # CoinStats panel needs account info for status display - @mercury_items = Current.family.mercury_items.active.ordered.includes(:syncs, :mercury_accounts) + @mercury_items = Current.family.mercury_items.active.ordered @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 + @snaptrade_items = Current.family.snaptrade_items.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(family_panel_items) + + 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 = view_context.provider_health_strip(connected: @connected, needs_attention: @needs_attention) + end + + # Maps each family panel key to the loaded item collection. Used by + # compute_provider_sync_health and build_provider_entries to avoid relying + # on instance_variable_get for control flow. + def family_panel_items + { + "simplefin" => @simplefin_items, + "lunchflow" => @lunchflow_items, + "enable_banking" => @enable_banking_items, + "coinstats" => @coinstats_items, + "mercury" => @mercury_items, + "coinbase" => @coinbase_items, + "binance" => @binance_items, + "snaptrade" => @snaptrade_items, + "indexa_capital" => @indexa_capital_items, + "sophtron" => @sophtron_items + } + 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(items_map) + PANEL_SYNCABLE_TYPES.each_with_object({}) do |(key, syncable_type), health| + ids = items_map[key]&.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| + meta = Provider::Metadata.for(config.provider_key) + { + provider_key: config.provider_key.to_s, + title: meta[:name] || config.provider_key.to_s.titleize, + configuration: config, + maturity: meta[: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 diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 79a3c248a..3fc4f1af2 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -2,7 +2,7 @@ module SettingsHelper SETTINGS_ORDER = [ # General section { name: "Accounts", path: :accounts_path }, - { name: "Bank Sync", path: :settings_bank_sync_path }, + { name: "Bank Sync", path: :settings_providers_path, condition: :admin_user? }, { name: "Preferences", path: :settings_preferences_path }, { name: "Appearance", path: :settings_appearance_path }, { name: "Profile Info", path: :settings_profile_path }, @@ -19,7 +19,6 @@ module SettingsHelper { name: "LLM Usage", path: :settings_llm_usage_path, condition: :admin_user? }, { name: "API Key", path: :settings_api_key_path, condition: :admin_user? }, { name: "Self-Hosting", path: :settings_hosting_path, condition: :self_hosted_and_admin? }, - { name: "Providers", path: :settings_providers_path, condition: :admin_user? }, { name: "Imports", path: :imports_path, condition: :admin_user? }, { name: "Exports", path: :family_exports_path, condition: :admin_user? }, # More section @@ -45,9 +44,70 @@ module SettingsHelper } end - def settings_section(title:, subtitle: nil, collapsible: false, open: true, auto_open_param: nil, &block) + def settings_section(title:, subtitle: nil, collapsible: false, open: true, auto_open_param: nil, status: nil, meta: nil, actions: nil, badge: nil, &block) content = capture(&block) - render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content, collapsible: collapsible, open: open, auto_open_param: auto_open_param } + render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content, collapsible: collapsible, open: open, auto_open_param: auto_open_param, status: status, meta: meta, actions: actions, badge: badge } + end + + def status_pill_classes(status) + pill = "bg-surface-inset text-primary" + + case status.to_s.to_sym + when :ok + { dot: "bg-success", pill: pill } + when :warn + { dot: "bg-warning", pill: pill } + when :err + { dot: "bg-destructive", pill: pill } + else + { dot: "bg-gray-400", pill: pill } + end + end + + def provider_summary(provider_key) + key = provider_key.to_s.downcase + + case key + when "plaid", "plaid_eu" + configured = @provider_configurations&.find { |c| c.provider_key.to_s.casecmp(key).zero? }&.configured? + configured ? { status: :ok } : { status: :off } + when "simplefin" + return { status: :off } unless @simplefin_items&.any? + sync_based_summary(key) + when "lunchflow" + return { status: :off } unless @lunchflow_items&.any? + sync_based_summary(key) + when "enable_banking" + return { status: :off } unless @enable_banking_items&.any? + enable_banking_summary + when "coinstats" + return { status: :off } unless @coinstats_items&.any? + sync_based_summary(key) + when "mercury" + return { status: :off } unless @mercury_items&.any? + sync_based_summary(key) + when "coinbase" + return { status: :off } unless @coinbase_items&.any? + sync_based_summary(key) + when "binance" + return { status: :off } unless @binance_items&.any? + sync_based_summary(key) + when "snaptrade" + configured_item = @snaptrade_items&.find(&:credentials_configured?) + return { status: :off } unless configured_item + unless configured_item.user_registered? + return { status: :warn, meta: t("settings.providers.meta.registration_needed") } + end + sync_based_summary(key) + when "indexa_capital" + return { status: :off } unless @indexa_capital_items&.any? + sync_based_summary(key) + when "sophtron" + return { status: :off } unless @sophtron_items&.any? + sync_based_summary(key) + else + { status: :off } + end end def settings_nav_footer @@ -70,7 +130,85 @@ module SettingsHelper end end + # Below this many synced accounts, the per-row pills already give the user + # enough at-a-glance signal and the strip is redundant chrome. + HEALTH_STRIP_MIN_ACCOUNTS = 10 + + # Slim health-strip data for the providers index. Pulls counts from the + # already-resolved entry summaries plus the family's distinct synced-account + # count for the trailing stat. Returns a hash consumed by the + # `settings/providers/_health_strip` partial, or nil when the family has + # fewer than HEALTH_STRIP_MIN_ACCOUNTS connected accounts. + def provider_health_strip(connected:, needs_attention:) + accounts_count = Current.family.accounts.joins(:account_providers).distinct.count + return nil if accounts_count < HEALTH_STRIP_MIN_ACCOUNTS + + active_entries = connected + needs_attention + last_synced_at = active_entries.map { |e| e[:summary][:last_synced_at] }.compact.max + + { + connected: active_entries.size, + needs_attention: needs_attention.size, + accounts_syncing: accounts_count, + last_synced_at: last_synced_at + } + end + + # Strips the leading "about " from `time_ago_in_words` so copy reads as + # "Synced 6 hours ago" instead of "Synced about 6 hours ago". + def concise_time_ago(time) + time_ago_in_words(time).sub(/\Aabout /, "") + end + private + def sync_based_summary(provider_key) + health = @provider_sync_health&.dig(provider_key) || {} + last_synced_at = health[:last_synced_at] + + base = if health[:error] + { status: :err, meta: t("settings.providers.meta.sync_error") } + elsif health[:stale] + { status: :warn, meta: t("settings.providers.meta.no_recent_sync") } + elsif last_synced_at.present? + { status: :ok, meta: t("settings.providers.meta.last_synced", time: concise_time_ago(last_synced_at)) } + else + { status: :ok } + end + + base.merge(last_synced_at: last_synced_at) + end + + def enable_banking_summary + health = @provider_sync_health&.dig("enable_banking") || {} + last_synced_at = health[:last_synced_at] + + return { status: :err, meta: t("settings.providers.meta.sync_error"), last_synced_at: nil } if health[:error] + + valid_items = @enable_banking_items&.select(&:session_valid?) || [] + + # All items have expired/missing sessions — need re-authorization + if valid_items.empty? + return { status: :warn, meta: t("settings.providers.meta.reconsent_required"), last_synced_at: last_synced_at } + end + + expiring = valid_items.find do |item| + item.session_expires_at.present? && item.session_expires_at < 7.days.from_now + end + + if expiring + days = [ ((expiring.session_expires_at - Time.current) / 1.day).ceil, 1 ].max + return { status: :warn, meta: t("settings.providers.meta.reconsent_needed", count: days), last_synced_at: last_synced_at } + end + + return { status: :warn, meta: t("settings.providers.meta.no_recent_sync"), last_synced_at: last_synced_at } if health[:stale] + + if last_synced_at.present? + { status: :ok, meta: t("settings.providers.meta.last_synced", time: concise_time_ago(last_synced_at)), last_synced_at: last_synced_at } + else + { status: :ok, last_synced_at: nil } + end + end + def not_self_hosted? !self_hosted? end diff --git a/app/javascript/controllers/providers_filter_controller.js b/app/javascript/controllers/providers_filter_controller.js new file mode 100644 index 000000000..54004b2f6 --- /dev/null +++ b/app/javascript/controllers/providers_filter_controller.js @@ -0,0 +1,68 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="providers-filter" +// Filters provider cards by free-text query and a chip-selected kind. +// Updates the visible-count target on the section heading and toggles +// an empty-state target when no card matches. +export default class extends Controller { + static targets = ["input", "chip", "card", "empty", "count"]; + static values = { kind: { type: String, default: "all" } }; + + connect() { + this.syncChipState(); + } + + filter() { + const query = this.hasInputTarget + ? this.inputTarget.value.toLocaleLowerCase().trim() + : ""; + const activeKind = this.kindValue; + let visibleCount = 0; + + this.cardTargets.forEach((card) => { + const name = card.dataset.providerName ?? ""; + const region = card.dataset.providerRegion ?? ""; + const kind = card.dataset.providerKind ?? ""; + const haystack = `${name} ${region} ${kind}`; + const matchesQuery = !query || haystack.includes(query); + const matchesKind = activeKind === "all" || kind === activeKind; + const visible = matchesQuery && matchesKind; + card.classList.toggle("hidden", !visible); + if (visible) visibleCount++; + }); + + if (this.hasCountTarget) { + this.countTarget.textContent = visibleCount; + } + + if (this.hasEmptyTarget) { + this.emptyTarget.classList.toggle("hidden", visibleCount > 0); + } + } + + selectChip(event) { + this.kindValue = event.currentTarget.dataset.kind ?? "all"; + this.syncChipState(); + this.filter(); + } + + clear() { + if (this.hasInputTarget) this.inputTarget.value = ""; + this.kindValue = "all"; + this.syncChipState(); + this.filter(); + if (this.hasInputTarget) this.inputTarget.focus(); + } + + syncChipState() { + if (!this.hasChipTarget) return; + this.chipTargets.forEach((chip) => { + const active = chip.dataset.kind === this.kindValue; + chip.classList.toggle("bg-container", active); + chip.classList.toggle("shadow-border-xs", active); + chip.classList.toggle("text-primary", active); + chip.classList.toggle("text-secondary", !active); + chip.setAttribute("aria-pressed", active ? "true" : "false"); + }); + } +} diff --git a/app/jobs/sync_all_providers_job.rb b/app/jobs/sync_all_providers_job.rb new file mode 100644 index 000000000..431b77cad --- /dev/null +++ b/app/jobs/sync_all_providers_job.rb @@ -0,0 +1,9 @@ +class SyncAllProvidersJob < ApplicationJob + queue_as :high_priority + sidekiq_options lock: :until_executed, lock_args: ->(args) { [ args.first ] }, on_conflict: :log + + def perform(family_id) + family = Family.find_by(id: family_id) + family&.sync_later + end +end diff --git a/app/models/family/syncer.rb b/app/models/family/syncer.rb index 3eace0b06..6b909ebcb 100644 --- a/app/models/family/syncer.rb +++ b/app/models/family/syncer.rb @@ -17,6 +17,7 @@ class Family::Syncer coinbase_items coinstats_items mercury_items + binance_items snaptrade_items sophtron_items ].freeze diff --git a/app/models/provider/metadata.rb b/app/models/provider/metadata.rb new file mode 100644 index 000000000..5cdd8e72c --- /dev/null +++ b/app/models/provider/metadata.rb @@ -0,0 +1,22 @@ +class Provider + module Metadata + REGISTRY = { + simplefin: { region: "US", kind: "Bank", maturity: :stable, logo_text: "SF", logo_bg: "bg-blue-600" }, + lunchflow: { region: "US", kind: "Bank", maturity: :stable, logo_text: "LF", logo_bg: "bg-orange-500" }, + enable_banking: { region: "EU", kind: "Bank", maturity: :beta, logo_text: "EB", logo_bg: "bg-purple-600" }, + coinstats: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "CS", logo_bg: "bg-pink-600" }, + mercury: { region: "US", kind: "Bank", maturity: :beta, logo_text: "ME", logo_bg: "bg-cyan-600" }, + coinbase: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "CB", logo_bg: "bg-blue-500" }, + binance: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "BI", logo_bg: "bg-yellow-600" }, + snaptrade: { region: "US / CA", kind: "Investment", maturity: :beta, logo_text: "ST", logo_bg: "bg-green-600" }, + indexa_capital: { region: "ES", kind: "Investment", maturity: :alpha, logo_text: "IC", logo_bg: "bg-red-600" }, + sophtron: { region: "US", kind: "Bank", maturity: :alpha, logo_text: "SO", logo_bg: "bg-teal-600" }, + plaid: { region: "US", kind: "Bank", tier: "Paid", maturity: :stable, logo_text: "PL", logo_bg: "bg-indigo-600" }, + plaid_eu: { name: "Plaid EU", region: "EU", kind: "Bank", tier: "Paid", maturity: :stable, logo_text: "PL", logo_bg: "bg-indigo-600" } + }.freeze + + def self.for(provider_key) + REGISTRY[provider_key.to_sym] || { logo_text: provider_key.to_s.first(2).upcase, logo_bg: "bg-gray-500" } + end + end +end diff --git a/app/models/provider/plaid_adapter.rb b/app/models/provider/plaid_adapter.rb index ac4fd8187..f75c5d97f 100644 --- a/app/models/provider/plaid_adapter.rb +++ b/app/models/provider/plaid_adapter.rb @@ -80,13 +80,6 @@ class Provider::PlaidAdapter < Provider::Base # Configuration for Plaid US configure do - description <<~DESC - Setup instructions: - 1. Visit the [Plaid Dashboard](https://dashboard.plaid.com/team/keys) to get your API credentials - 2. Your Client ID and Secret Key are required to enable Plaid bank sync for US/CA banks - 3. For production use, set environment to 'production', for testing use 'sandbox' - DESC - field :client_id, label: "Client ID", required: false, diff --git a/app/models/provider/plaid_eu_adapter.rb b/app/models/provider/plaid_eu_adapter.rb index 497bb13c4..6db5331a0 100644 --- a/app/models/provider/plaid_eu_adapter.rb +++ b/app/models/provider/plaid_eu_adapter.rb @@ -19,13 +19,6 @@ class Provider::PlaidEuAdapter # Configuration for Plaid EU configure do - description <<~DESC - Setup instructions: - 1. Visit the [Plaid Dashboard](https://dashboard.plaid.com/team/keys) to get your API credentials - 2. Your Client ID and Secret Key are required to enable Plaid bank sync for European banks - 3. For production use, set environment to 'production', for testing use 'sandbox' - DESC - field :client_id, label: "Client ID", required: false, diff --git a/app/views/settings/_section.html.erb b/app/views/settings/_section.html.erb index bb5873839..0c05833e8 100644 --- a/app/views/settings/_section.html.erb +++ b/app/views/settings/_section.html.erb @@ -1,4 +1,4 @@ -<%# locals: (title:, subtitle: nil, content:, collapsible: false, open: true, auto_open_param: nil) %> +<%# locals: (title:, subtitle: nil, content:, collapsible: false, open: true, auto_open_param: nil, status: nil, meta: nil, actions: nil, badge: nil) %> <% if collapsible %>
class="group bg-container shadow-border-xs rounded-xl p-4" @@ -7,12 +7,24 @@
<%= icon "chevron-right", class: "text-secondary group-open:transform group-open:rotate-90 transition-transform" %>
-

<%= title %>

+
+

<%= title %>

+ <%= badge if badge.present? %> +
<% if subtitle.present? %>

<%= subtitle %>

<% end %>
+ <% if status.present? %> +
+ <% if meta.present? %> + <%= meta %> + <% end %> + <%= status %> + <%= actions if actions.present? %> +
+ <% end %>
<%= content %> diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb index bc4f9dcd0..94df67ae2 100644 --- a/app/views/settings/_settings_nav.html.erb +++ b/app/views/settings/_settings_nav.html.erb @@ -4,7 +4,7 @@ nav_sections = [ header: t(".general_section_title"), items: [ { label: t(".accounts_label"), path: accounts_path, icon: "layers" }, - { label: t(".bank_sync_label"), path: settings_bank_sync_path, icon: "banknote" }, + { label: t(".bank_sync_label"), path: settings_providers_path, icon: "banknote", if: Current.user&.admin? }, { label: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" }, { label: t(".appearance_label"), path: settings_appearance_path, icon: "palette" }, { label: t(".profile_label"), path: settings_profile_path, icon: "circle-user" }, @@ -30,7 +30,6 @@ nav_sections = [ { label: "LLM Usage", path: settings_llm_usage_path, icon: "activity" }, { label: t(".api_keys_label"), path: settings_api_key_path, icon: "key" }, { label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? }, - { label: "Providers", path: settings_providers_path, icon: "plug" }, { label: t(".imports_label"), path: imports_path, icon: "download" }, { label: t(".exports_label"), path: family_exports_path, icon: "upload" }, { label: "SSO Providers", path: admin_sso_providers_path, icon: "key-round", if: Current.user&.super_admin? }, diff --git a/app/views/settings/bank_sync/_provider_link.html.erb b/app/views/settings/bank_sync/_provider_link.html.erb deleted file mode 100644 index 6cb50df92..000000000 --- a/app/views/settings/bank_sync/_provider_link.html.erb +++ /dev/null @@ -1,33 +0,0 @@ -<%# locals: (provider_link:) %> - -<%# Assign distinct colors to each provider %> -<% provider_colors = { - "Lunch Flow" => "#6471eb", - "Plaid" => "#4da568", - "SimpleFin" => "#e99537", - "Enable Banking" => "#6471eb", - "CoinStats" => "#FF9332", # https://coinstats.app/press-kit/ - "Sophtron" => "#1E90FF" -} %> -<% provider_color = provider_colors[provider_link[:name]] || "#6B7280" %> - -<%= link_to provider_link[:path], - target: provider_link[:target], - rel: provider_link[:rel], - class: "flex justify-between items-center p-4 bg-container hover:bg-container-hover transition-colors" do %> -
- <%= render partial: "shared/color_avatar", locals: { name: provider_link[:name], color: provider_color } %> - -
-

- <%= provider_link[:name] %> -

-

- <%= provider_link[:description] %> -

-
-
-
- <%= icon("arrow-right", size: "sm", class: "text-secondary") %> -
-<% end %> diff --git a/app/views/settings/bank_sync/show.html.erb b/app/views/settings/bank_sync/show.html.erb deleted file mode 100644 index 51c42bfcb..000000000 --- a/app/views/settings/bank_sync/show.html.erb +++ /dev/null @@ -1,26 +0,0 @@ -<%= content_for :page_title, "Bank Sync" %> - -
- <% if @providers.any? %> -
-
-

PROVIDERS

- · -

<%= @providers.count %>

-
- -
-
- <%= render partial: "provider_link", collection: @providers, spacer_template: "shared/ruler" %> -
-
-
- <% else %> -
-
-

No providers configured

-

Configure providers to link your bank accounts.

-
-
- <% end %> -
diff --git a/app/views/settings/providers/_binance_panel.html.erb b/app/views/settings/providers/_binance_panel.html.erb index 05378904a..f93eeea47 100644 --- a/app/views/settings/providers/_binance_panel.html.erb +++ b/app/views/settings/providers/_binance_panel.html.erb @@ -1,32 +1,39 @@
<% items = local_assigns[:binance_items] || @binance_items || Current.family.binance_items.active.ordered %> -
-

<%= t("settings.providers.binance_panel.setup_instructions") %>

-
    -
  1. <%= t("settings.providers.binance_panel.step1_html").html_safe %>
  2. -
  3. <%= t("settings.providers.binance_panel.step2") %>
  4. -
  5. <%= t("settings.providers.binance_panel.step3") %>
  6. -
-

<%= t("settings.providers.binance_panel.no_withdraw_warning") %>

-
+ <%= render DS::Alert.new( + variant: :warning, + message: safe_join([ + content_tag(:p, t("settings.providers.binance_panel.no_withdraw_title"), class: "font-medium"), + content_tag(:p, t("settings.providers.binance_panel.no_withdraw_body"), class: "mt-1") + ]) + ) %> -
-

<%= t("settings.providers.binance_panel.ip_hint_title") %>

-

<%= t("settings.providers.binance_panel.ip_hint_body") %>

- <% server_ip = ENV["BINANCE_EGRESS_IP"].presence %> - <% if server_ip %> - <%= server_ip %> - <% else %> -

<%= t("settings.providers.binance_panel.ip_hint_contact_admin") %>

- <% end %> +
+ <%= render "settings/providers/setup_steps", + steps: [ + t("settings.providers.binance_panel.step1_html").html_safe, + t("settings.providers.binance_panel.step2"), + t("settings.providers.binance_panel.step3") + ] %> + +
+

+ <%= t("settings.providers.binance_panel.ip_hint_title") %> +

+

<%= t("settings.providers.binance_panel.ip_hint_body") %>

+ <% server_ip = ENV["BINANCE_EGRESS_IP"].presence %> + <% if server_ip %> + <%= server_ip %> + <% else %> +

<%= t("settings.providers.binance_panel.ip_hint_contact_admin") %>

+ <% end %> +
<% error_msg = local_assigns[:error_message] || @error_message %> <% if error_msg.present? %> -
-

<%= error_msg %>

-
+ <%= render DS::Alert.new(message: error_msg, variant: :error) %> <% end %> <% if items.any? %> @@ -94,13 +101,4 @@ <% end %> <% end %> -
- <% if items.any? %> -
-

<%= t("settings.providers.binance_panel.status_connected") %>

- <% else %> -
-

<%= t("settings.providers.binance_panel.status_not_connected") %>

- <% end %> -
diff --git a/app/views/settings/providers/_coinbase_panel.html.erb b/app/views/settings/providers/_coinbase_panel.html.erb index 3cd62ff5d..546d32626 100644 --- a/app/views/settings/providers/_coinbase_panel.html.erb +++ b/app/views/settings/providers/_coinbase_panel.html.erb @@ -1,20 +1,16 @@
<% items = local_assigns[:coinbase_items] || @coinbase_items || Current.family.coinbase_items.active.ordered %> -
-

<%= t("settings.providers.coinbase_panel.setup_instructions") %>

-
    -
  1. <%= t("settings.providers.coinbase_panel.step1_html").html_safe %>
  2. -
  3. <%= t("settings.providers.coinbase_panel.step2") %>
  4. -
  5. <%= t("settings.providers.coinbase_panel.step3") %>
  6. -
-
+ <%= render "settings/providers/setup_steps", + steps: [ + t("settings.providers.coinbase_panel.step1_html").html_safe, + t("settings.providers.coinbase_panel.step2"), + t("settings.providers.coinbase_panel.step3") + ] %> <% error_msg = local_assigns[:error_message] || @error_message %> <% if error_msg.present? %> -
-

<%= error_msg %>

-
+ <%= render DS::Alert.new(message: error_msg, variant: :error) %> <% end %> <% if items.any? %> @@ -82,13 +78,4 @@ <% end %> <% end %> -
- <% if items.any? %> -
-

<%= t("settings.providers.coinbase_panel.status_connected") %>

- <% else %> -
-

<%= t("settings.providers.coinbase_panel.status_not_connected") %>

- <% end %> -
diff --git a/app/views/settings/providers/_coinstats_panel.html.erb b/app/views/settings/providers/_coinstats_panel.html.erb index c5359e104..b9e62b071 100644 --- a/app/views/settings/providers/_coinstats_panel.html.erb +++ b/app/views/settings/providers/_coinstats_panel.html.erb @@ -1,18 +1,14 @@
-
-

<%= t("coinstats_items.new.setup_instructions") %>

-
    -
  1. <%= t("coinstats_items.new.step1_html").html_safe %>
  2. -
  3. <%= t("coinstats_items.new.step2") %>
  4. -
  5. <%= t("coinstats_items.new.step3_html", accounts_url: accounts_path).html_safe %>
  6. -
-
+ <%= render "settings/providers/setup_steps", + steps: [ + t("coinstats_items.new.step1_html").html_safe, + t("coinstats_items.new.step2"), + t("coinstats_items.new.step3_html", accounts_url: accounts_path).html_safe + ] %> <% error_msg = local_assigns[:error_message] || @error_message %> <% if error_msg.present? %> -
-

<%= error_msg %>

-
+ <%= render DS::Alert.new(message: error_msg, variant: :error) %> <% end %> <% @@ -41,14 +37,4 @@
<% end %> - <% items = local_assigns[:coinstats_items] || @coinstats_items || Current.family.coinstats_items.where.not(api_key: [nil, ""]) %> -
- <% if items&.any? %> -
-

<%= t("coinstats_items.new.status_configured_html", accounts_url: accounts_path).html_safe %>

- <% else %> -
-

<%= t("coinstats_items.new.status_not_configured") %>

- <% end %> -
diff --git a/app/views/settings/providers/_connection_row.html.erb b/app/views/settings/providers/_connection_row.html.erb new file mode 100644 index 000000000..397e8ee15 --- /dev/null +++ b/app/views/settings/providers/_connection_row.html.erb @@ -0,0 +1,43 @@ +<%# locals: (entry:, open:) %> +<% + status = entry[:summary][:status] + meta = entry[:summary][:meta] + last_synced = entry[:summary][:last_synced_at] + border_class = + case status + when :warn then "border border-warning/25" + when :err then "border border-destructive/25" + else "border border-transparent" + end + sync_action = entry[:partial].present? ? render("settings/providers/sync_button", provider_key: entry[:provider_key], last_synced_at: last_synced) : nil + status_pill = render("settings/providers/status_pill", status: status) + maturity_lbl = Settings::ProviderCard.maturity_label(entry[:maturity]) + details_data = entry[:auto_open_param].present? ? { controller: "auto-open", auto_open_param_value: entry[:auto_open_param] } : {} +%> +<%= tag.details open: open, + class: "group bg-container shadow-border-xs rounded-xl #{border_class}", + data: details_data do %> + + <%= icon "chevron-right", size: "sm", class: "!w-3.5 !h-3.5 text-secondary group-open:rotate-90 transition-transform" %> +
+

<%= entry[:title] %>

+ <%= render "settings/providers/maturity_badge", label: maturity_lbl %> +
+
+ <% if meta.present? %> + <%= meta %> + <% end %> + <%= status_pill %> + <%= sync_action if sync_action %> +
+
+
+ <% if entry[:configuration] %> + <%= render "settings/providers/provider_form", configuration: entry[:configuration] %> + <% else %> + + <%= render "settings/providers/#{entry[:partial]}" %> + + <% end %> +
+<% end %> diff --git a/app/views/settings/providers/_drawer_header.html.erb b/app/views/settings/providers/_drawer_header.html.erb new file mode 100644 index 000000000..df439dafd --- /dev/null +++ b/app/views/settings/providers/_drawer_header.html.erb @@ -0,0 +1,22 @@ +<%# locals: (provider_key:, title:) %> +<% meta = provider_key.present? ? Provider::Metadata.for(provider_key) : nil %> +<% maturity_label = meta ? Settings::ProviderCard.maturity_label(meta[:maturity]) : nil %> +
+
+ <% if meta && meta[:logo_bg].present? %> + + <%= meta[:logo_text] %> + + <% end %> +

<%= title %>

+ <%= render "settings/providers/maturity_badge", label: maturity_label %> +
+ <%= render DS::Button.new( + variant: "icon", + class: "ml-auto hidden lg:flex", + icon: "x", + title: t("common.close"), + aria_label: t("common.close"), + data: { action: "DS--dialog#close" } + ) %> +
diff --git a/app/views/settings/providers/_enable_banking_panel.html.erb b/app/views/settings/providers/_enable_banking_panel.html.erb index d778d8759..4085520ef 100644 --- a/app/views/settings/providers/_enable_banking_panel.html.erb +++ b/app/views/settings/providers/_enable_banking_panel.html.erb @@ -1,27 +1,18 @@
-
-

Setup instructions:

-
    -
  1. Visit your Enable Banking developer account to get your credentials
  2. -
  3. Select your country code from the dropdown below
  4. -
  5. Enter your Application ID and paste your Client Certificate (including the private key)
  6. -
  7. Click Save Configuration, then use "Add Connection" to link your bank
  8. -
  9. <%= t("settings.providers.enable_banking_panel.callback_url_instruction", callback_url: enable_banking_callback_url) %>
  10. -
- -

Field descriptions:

-
    -
  • Country Code: ISO 3166-1 alpha-2 country code (e.g., GB, DE, FR) - determines available banks
  • -
  • Application ID: The ID generated in your Enable Banking developer account
  • -
  • Client Certificate: The certificate generated when you created your application (must include the private key)
  • -
-
+ <% + eb_link = link_to("Enable Banking", "https://enablebanking.com", target: "_blank", rel: "noopener noreferrer", class: "text-primary font-medium underline") + %> + <%= render "settings/providers/setup_steps", + steps: [ + t("settings.providers.enable_banking_panel.step_1_html", link: eb_link), + t("settings.providers.enable_banking_panel.step_2"), + t("settings.providers.enable_banking_panel.step_3"), + t("settings.providers.enable_banking_panel.callback_url_instruction", callback_url: enable_banking_callback_url) + ] %> <% error_msg = local_assigns[:error_message] || @error_message %> <% if error_msg.present? %> -
-

<%= error_msg %>

-
+ <%= render DS::Alert.new(message: error_msg, variant: :error) %> <% end %> <% @@ -78,10 +69,13 @@ { label: "Country", class: "form-field__input" } %> <% if has_authenticated_connections && !is_new_record %> -
-

Configuration locked

-

Credentials cannot be changed while you have active bank connections. Remove all connections first to update credentials.

-
+ <%= render DS::Alert.new( + variant: :warning, + message: safe_join([ + content_tag(:p, "Configuration locked", class: "font-medium"), + content_tag(:p, "Disconnect all linked banks before changing these credentials.", class: "mt-1") + ]) + ) %> <% end %> <%= form.text_field :application_id, @@ -98,7 +92,7 @@ disabled: has_authenticated_connections && !is_new_record %>
- <%= form.submit is_new_record ? "Save Configuration" : "Update Configuration", + <%= form.submit is_new_record ? "Save and connect" : "Update connection", class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-gray-900 theme-dark:focus:ring-white focus:ring-offset-2 transition-colors" %>
<% end %> @@ -169,7 +163,7 @@ <%= link_to select_bank_enable_banking_item_path(item), class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-inverse button-bg-primary hover:button-bg-primary-hover transition-colors", data: { turbo_frame: "modal" } do %> - Connect Bank + Connect bank <% end %> <% end %> diff --git a/app/views/settings/providers/_group_heading.html.erb b/app/views/settings/providers/_group_heading.html.erb new file mode 100644 index 000000000..e0a831f49 --- /dev/null +++ b/app/views/settings/providers/_group_heading.html.erb @@ -0,0 +1,12 @@ +<%# locals: (title:, count: nil, description: nil, anchor: nil) %> +<%= tag.div id: anchor.presence, class: "flex items-baseline justify-between gap-3 mt-6 mb-1.5 px-1" do %> +

+ <%= title %> + <% if count %> + · <%= count %> + <% end %> +

+ <% if description.present? %> +

<%= description %>

+ <% end %> +<% end %> diff --git a/app/views/settings/providers/_health_strip.html.erb b/app/views/settings/providers/_health_strip.html.erb new file mode 100644 index 000000000..1a30989f7 --- /dev/null +++ b/app/views/settings/providers/_health_strip.html.erb @@ -0,0 +1,28 @@ +<%# locals: (connected:, needs_attention:, accounts_syncing:, last_synced_at:) %> +
+ + <%= icon "check", size: "sm", class: "!w-3.5 !h-3.5 text-success" %> + <%= connected %> + <%= t("settings.providers.health_strip.connected") %> + + <% if needs_attention.positive? %> + + + <%= icon "circle-alert", size: "sm", class: "!w-3.5 !h-3.5 text-warning" %> + <%= needs_attention %> + <%= t("settings.providers.health_strip.needs_attention") %> + + <% end %> + <% if accounts_syncing.positive? %> + + + <%= accounts_syncing %> + <%= t("settings.providers.health_strip.accounts_syncing") %> + + <% end %> + <% if last_synced_at %> + + <%= t("settings.providers.health_strip.last_synced", time: concise_time_ago(last_synced_at)) %> + + <% end %> +
diff --git a/app/views/settings/providers/_indexa_capital_panel.html.erb b/app/views/settings/providers/_indexa_capital_panel.html.erb index c31ec5f1c..c0fbdcaf1 100644 --- a/app/views/settings/providers/_indexa_capital_panel.html.erb +++ b/app/views/settings/providers/_indexa_capital_panel.html.erb @@ -1,18 +1,14 @@
-
-

<%= t("indexa_capital_items.panel.setup_instructions") %>

-
    -
  1. <%= t("indexa_capital_items.panel.step_1") %>
  2. -
  3. <%= t("indexa_capital_items.panel.step_2") %>
  4. -
  5. <%= t("indexa_capital_items.panel.step_3") %>
  6. -
-
+ <%= render "settings/providers/setup_steps", + steps: [ + t("indexa_capital_items.panel.step_1"), + t("indexa_capital_items.panel.step_2"), + t("indexa_capital_items.panel.step_3") + ] %> <% error_msg = local_assigns[:error_message] || @error_message %> <% if error_msg.present? %> -
-

<%= error_msg %>

-
+ <%= render DS::Alert.new(message: error_msg, variant: :error) %> <% end %> <% @@ -27,14 +23,11 @@ data: { turbo: true }, class: "space-y-3" do |form| %> -
-

<%= t("indexa_capital_items.panel.fields.api_token.label") %>

-

<%= t("indexa_capital_items.panel.fields.api_token.description") %>

- <%= form.text_field :api_token, - label: t("indexa_capital_items.panel.fields.api_token.label"), - placeholder: is_new_record ? t("indexa_capital_items.panel.fields.api_token.placeholder_new") : t("indexa_capital_items.panel.fields.api_token.placeholder_update"), - type: :password %> -
+ <%= form.text_field :api_token, + label: t("indexa_capital_items.panel.fields.api_token.label"), + placeholder: is_new_record ? t("indexa_capital_items.panel.fields.api_token.placeholder_new") : t("indexa_capital_items.panel.fields.api_token.placeholder_update"), + type: :password %> +

<%= t("indexa_capital_items.panel.fields.api_token.description") %>

@@ -64,14 +57,4 @@
<% end %> - <% items = local_assigns[:indexa_capital_items] || @indexa_capital_items || Current.family.indexa_capital_items.where.not(username: [nil, ""], document: [nil, ""], password: [nil, ""]).or(Current.family.indexa_capital_items.where.not(api_token: [nil, ""])) %> -
- <% if items&.any? %> -
-

<%= t("indexa_capital_items.panel.status_configured_html", accounts_path: accounts_path).html_safe %>

- <% else %> -
-

<%= t("indexa_capital_items.panel.status_not_configured") %>

- <% end %> -
diff --git a/app/views/settings/providers/_lunchflow_panel.html.erb b/app/views/settings/providers/_lunchflow_panel.html.erb index b54c49810..e4bccf2ba 100644 --- a/app/views/settings/providers/_lunchflow_panel.html.erb +++ b/app/views/settings/providers/_lunchflow_panel.html.erb @@ -1,24 +1,17 @@
-
-

Setup instructions:

-
    -
  1. Visit Lunch Flow to get your API key
  2. -
  3. Paste your API key below and click the Save button
  4. -
  5. After a successful connection, go to the Accounts tab to set up new accounts and link them to your existing ones
  6. -
- -

Field descriptions:

-
    -
  • API Key: Your Lunch Flow API key for authentication (required)
  • -
  • Base URL: Base URL for Lunch Flow API (optional, defaults to https://lunchflow.app/api/v1)
  • -
-
+ <% + lf_link = link_to("Lunch Flow", "https://www.lunchflow.app/?atp=BiDIYS", target: "_blank", rel: "noopener noreferrer", class: "text-primary font-medium underline") + %> + <%= render "settings/providers/setup_steps", + steps: [ + t("settings.providers.lunchflow_panel.step_1_html", link: lf_link), + t("settings.providers.lunchflow_panel.step_2"), + t("settings.providers.lunchflow_panel.step_3") + ] %> <% error_msg = local_assigns[:error_message] || @error_message %> <% if error_msg.present? %> -
-

<%= error_msg %>

-
+ <%= render DS::Alert.new(message: error_msg, variant: :error) %> <% end %> <% @@ -44,19 +37,9 @@ value: lunchflow_item.base_url %>
- <%= form.submit is_new_record ? "Save Configuration" : "Update Configuration", + <%= form.submit is_new_record ? "Save and connect" : "Update connection", class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-gray-900 theme-dark:focus:ring-white focus:ring-offset-2 transition-colors" %>
<% end %> - <% items = local_assigns[:lunchflow_items] || @lunchflow_items || Current.family.lunchflow_items.where.not(api_key: nil) %> -
- <% if items&.any? %> -
-

Configured and ready to use. Visit the Accounts tab to manage and set up accounts.

- <% else %> -
-

Not configured

- <% end %> -
diff --git a/app/views/settings/providers/_maturity_badge.html.erb b/app/views/settings/providers/_maturity_badge.html.erb new file mode 100644 index 000000000..56127edec --- /dev/null +++ b/app/views/settings/providers/_maturity_badge.html.erb @@ -0,0 +1,4 @@ +<%# locals: (label:) %> +<% if label %> + <%= label %> +<% end %> diff --git a/app/views/settings/providers/_mercury_panel.html.erb b/app/views/settings/providers/_mercury_panel.html.erb index 43173fa2b..47a7f8b20 100644 --- a/app/views/settings/providers/_mercury_panel.html.erb +++ b/app/views/settings/providers/_mercury_panel.html.erb @@ -2,26 +2,18 @@ <% active_items = local_assigns[:mercury_items] || @mercury_items || Current.family.mercury_items.active.ordered %> <% credentialed_items = active_items.select(&:credentials_configured?) %> -
-

<%= t("mercury_items.provider_panel.setup_title") %>

-
    -
  1. <%= t("mercury_items.provider_panel.instructions.sign_in_html", link: link_to("Mercury", "https://mercury.com", target: "_blank", rel: "noopener noreferrer", class: "link")) %>
  2. -
  3. <%= t("mercury_items.provider_panel.instructions.open_tokens") %>
  4. -
  5. <%= t("mercury_items.provider_panel.instructions.create_token") %>
  6. -
  7. <%= t("mercury_items.provider_panel.instructions.whitelist_ip_html") %>
  8. -
  9. <%= t("mercury_items.provider_panel.instructions.copy_token_html") %>
  10. -
- -

- <%= t("mercury_items.provider_panel.sandbox_note_html") %> -

-
+ <%= render "settings/providers/setup_steps", + steps: [ + t("mercury_items.provider_panel.instructions.sign_in_html", link: link_to("Mercury", "https://mercury.com", target: "_blank", rel: "noopener noreferrer", class: "text-primary font-medium underline")).html_safe, + t("mercury_items.provider_panel.instructions.open_tokens"), + t("mercury_items.provider_panel.instructions.create_token"), + t("mercury_items.provider_panel.instructions.whitelist_ip_html").html_safe, + t("mercury_items.provider_panel.instructions.copy_token_html").html_safe + ] %> <% error_msg = local_assigns[:error_message] || @error_message %> <% if error_msg.present? %> -
-

<%= error_msg %>

-
+ <%= render DS::Alert.new(message: error_msg, variant: :error) %> <% end %> <% if active_items.any? %> @@ -96,47 +88,38 @@
<% end %> -
class="group bg-container p-4 shadow-border-xs rounded-xl"> - - <%= icon "plus" %> + <% mercury_item = Current.family.mercury_items.build(name: t("mercury_items.provider_panel.default_connection_name")) %> + <% if active_items.any? %> +

+ <%= icon "plus", size: "sm" %> <%= t("mercury_items.provider_panel.add_connection") %> -

+ + <% end %> + <%= styled_form_with model: mercury_item, + url: mercury_items_path, + scope: :mercury_item, + method: :post, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <%= form.text_field :name, + label: t("mercury_items.provider_panel.connection_name_label"), + placeholder: t("mercury_items.provider_panel.connection_name_placeholder") %> - <% mercury_item = Current.family.mercury_items.build(name: t("mercury_items.provider_panel.default_connection_name")) %> - <%= styled_form_with model: mercury_item, - url: mercury_items_path, - scope: :mercury_item, - method: :post, - data: { turbo: true }, - class: "space-y-3 mt-4" do |form| %> - <%= form.text_field :name, - label: t("mercury_items.provider_panel.connection_name_label"), - placeholder: t("mercury_items.provider_panel.connection_name_placeholder") %> + <%= form.text_field :token, + label: t("mercury_items.provider_panel.token_label"), + placeholder: t("mercury_items.provider_panel.token_placeholder"), + type: :password, + value: nil %> - <%= form.text_field :token, - label: t("mercury_items.provider_panel.token_label"), - placeholder: t("mercury_items.provider_panel.token_placeholder"), - type: :password, - value: nil %> + <%= form.text_field :base_url, + label: t("mercury_items.provider_panel.base_url_label"), + placeholder: t("mercury_items.provider_panel.base_url_placeholder") %> +

<%= t("mercury_items.provider_panel.sandbox_note_html").html_safe %>

- <%= form.text_field :base_url, - label: t("mercury_items.provider_panel.base_url_label"), - placeholder: t("mercury_items.provider_panel.base_url_placeholder") %> +
+ <%= form.submit t("mercury_items.provider_panel.add_connection"), + class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover focus:outline-none focus:ring-2 focus:ring-primary transition-colors" %> +
+ <% end %> -
- <%= form.submit t("mercury_items.provider_panel.add_connection"), - class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover focus:outline-none focus:ring-2 focus:ring-primary transition-colors" %> -
- <% end %> -
- -
- <% if credentialed_items.any? %> -
-

<%= t("mercury_items.provider_panel.configured_html", accounts_link: link_to(t("mercury_items.provider_panel.accounts_link"), accounts_path, class: "link")) %>

- <% else %> -
-

<%= t("mercury_items.provider_panel.not_configured") %>

- <% end %> -
diff --git a/app/views/settings/providers/_provider_form.html.erb b/app/views/settings/providers/_provider_form.html.erb index 5c86e3b3a..4a7294a65 100644 --- a/app/views/settings/providers/_provider_form.html.erb +++ b/app/views/settings/providers/_provider_form.html.erb @@ -1,34 +1,35 @@ <% # Parameters: # - configuration: Provider::Configurable::Configuration object + provider_key = configuration.provider_key.to_s + + setup_steps_data = + if %w[plaid plaid_eu].include?(provider_key) + plaid_link = link_to("Plaid Dashboard", "https://dashboard.plaid.com/team/keys", target: "_blank", rel: "noopener noreferrer", class: "text-primary font-medium underline") + step_1_key = "settings.providers.#{provider_key}_panel.step_1_html" + [ + t(step_1_key, link: plaid_link), + t("settings.providers.plaid_panel.step_2"), + t("settings.providers.plaid_panel.step_3") + ] + end %>
-
- <% if configuration.provider_description.present? %> -
- <%= markdown(configuration.provider_description).html_safe %> -
- <% end %> + <% if setup_steps_data %> + <%= render "settings/providers/setup_steps", steps: setup_steps_data %> + <% elsif configuration.provider_description.present? %> +
+ <%= markdown(configuration.provider_description).html_safe %> +
+ <% end %> - <% env_configured = configuration.fields.any? { |f| f.env_key && ENV[f.env_key].present? } %> - <% if env_configured %> -

- Configuration can be set via environment variables or overridden below. -

- <% end %> - - <% if configuration.fields.any? { |f| f.description.present? } %> -

Field descriptions:

-
    - <% configuration.fields.each do |field| %> - <% if field.description.present? %> -
  • <%= field.label %>: <%= field.description %>
  • - <% end %> - <% end %> -
- <% end %> -
+ <% env_configured = configuration.fields.any? { |f| f.env_key && ENV[f.env_key].present? } %> + <% if env_configured %> +

+ Configuration can be set via environment variables or overridden below. +

+ <% end %> <%= styled_form_with model: Setting.new, url: settings_providers_path, @@ -37,50 +38,34 @@ <% configuration.fields.each do |field| %> <% env_value = ENV[field.env_key] if field.env_key - # Use dynamic hash-style access - works without explicit field declaration setting_value = Setting[field.setting_key] - - # Show the setting value if it exists, otherwise show ENV value - # This allows users to see what they've overridden current_value = setting_value.presence || env_value - # Mask secret values if they exist display_value = if field.secret && current_value.present? "********" else current_value end - # Determine input type input_type = field.secret ? "password" : "text" - - # Don't disable fields - allow overriding ENV variables - disabled = false %> - <%= form.text_field field.setting_key, - label: field.label, - type: input_type, - placeholder: field.default || (field.required ? "" : "Optional"), - value: display_value, - disabled: disabled %> +
+ <%= form.text_field field.setting_key, + label: field.label, + type: input_type, + placeholder: field.default || (field.required ? "" : "Optional"), + value: display_value %> + <% if field.description.present? %> +

<%= field.description %>

+ <% end %> +
<% end %>
- <%= form.submit "Save Configuration", + <%= form.submit "Save and connect", class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-gray-900 theme-dark:focus:ring-white focus:ring-offset-2 transition-colors" %>
<% end %> - - <%# Show configuration status %> -
- <% if configuration.configured? %> -
-

Configured and ready to use

- <% else %> -
-

Not configured

- <% end %> -
diff --git a/app/views/settings/providers/_search_filters.html.erb b/app/views/settings/providers/_search_filters.html.erb new file mode 100644 index 000000000..f4c295faf --- /dev/null +++ b/app/views/settings/providers/_search_filters.html.erb @@ -0,0 +1,27 @@ +
+
+ " + placeholder="<%= t("settings.providers.search_filters.placeholder") %>" + class="block w-full border border-secondary rounded-md py-2.5 pl-10 pr-3 bg-container focus:ring-gray-500 sm:text-sm"> +
+ <%= icon "search", class: "text-secondary" %> +
+
+
+ <% %w[all bank crypto investment].each do |kind| %> + <% active = kind == "all" %> + + <% end %> +
+
diff --git a/app/views/settings/providers/_setup_steps.html.erb b/app/views/settings/providers/_setup_steps.html.erb new file mode 100644 index 000000000..e3537b3bb --- /dev/null +++ b/app/views/settings/providers/_setup_steps.html.erb @@ -0,0 +1,26 @@ +<%# locals: (steps:, help: nil, eyebrow: nil) %> +<%# steps: array of strings (or html_safe strings; caller is responsible for safety). + help: optional hash { url:, text: } rendered under the steps with a small divider + book icon. + eyebrow: optional override for the localized "SETUP" eyebrow label. %> +
+

+ <%= eyebrow.presence || t("settings.providers.setup_steps.eyebrow") %> +

+
    + <% steps.each_with_index do |step, i| %> +
  1. + <%= i + 1 %> + <%= step %> +
  2. + <% end %> +
+ <% if help %> +
+ <%= icon "book-open", size: "sm", class: "!w-3 !h-3" %> + + <%= t("settings.providers.setup_steps.need_help") %> + <%= link_to help[:text], help[:url], class: "text-primary font-medium", target: "_blank", rel: "noopener noreferrer" %> + +
+ <% end %> +
diff --git a/app/views/settings/providers/_simplefin_panel.html.erb b/app/views/settings/providers/_simplefin_panel.html.erb index d4b6b5251..a20b6ac4b 100644 --- a/app/views/settings/providers/_simplefin_panel.html.erb +++ b/app/views/settings/providers/_simplefin_panel.html.erb @@ -1,22 +1,16 @@
-
-

Setup instructions:

-
    -
  1. Visit SimpleFIN Bridge to get your one-time setup token
  2. -
  3. Paste the token below and click the Save button to enable SimpleFIN bank data sync
  4. -
  5. After a successful connection, go to the Accounts tab to set up new accounts and link them to your existing ones
  6. -
- -

Field descriptions:

- -
+ <% + sf_link = link_to("SimpleFIN Bridge", "https://beta-bridge.simplefin.org", target: "_blank", rel: "noopener noreferrer", class: "text-primary font-medium underline") + %> + <%= render "settings/providers/setup_steps", + steps: [ + t("settings.providers.simplefin_panel.step_1_html", link: sf_link), + t("settings.providers.simplefin_panel.step_2"), + t("settings.providers.simplefin_panel.step_3") + ] %> <% if defined?(@error_message) && @error_message.present? %> -
-

<%= @error_message %>

-
+ <%= render DS::Alert.new(message: @error_message, variant: :error) %> <% end %> <%= styled_form_with model: SimplefinItem.new, @@ -31,18 +25,9 @@ type: :password %>
- <%= form.submit "Save Configuration", + <%= form.submit "Save and connect", class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-gray-900 theme-dark:focus:ring-white focus:ring-offset-2 transition-colors" %>
<% end %> -
- <% if @simplefin_items&.any? %> -
-

Configured and ready to use. Visit the Accounts tab to manage and set up accounts.

- <% else %> -
-

Not configured

- <% end %> -
diff --git a/app/views/settings/providers/_snaptrade_panel.html.erb b/app/views/settings/providers/_snaptrade_panel.html.erb index 314213acc..b99bace2b 100644 --- a/app/views/settings/providers/_snaptrade_panel.html.erb +++ b/app/views/settings/providers/_snaptrade_panel.html.erb @@ -1,23 +1,17 @@
-
-

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

+ <%= render DS::Alert.new(message: t("providers.snaptrade.free_tier_warning"), variant: :warning) %> -

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

-
    -
  1. <%= t("providers.snaptrade.step_1_html") %>
  2. -
  3. <%= t("providers.snaptrade.step_2") %>
  4. -
  5. <%= t("providers.snaptrade.step_3") %>
  6. -
  7. <%= t("providers.snaptrade.step_4") %>
  8. -
- -

<%= icon("alert-triangle", class: "inline-block w-4 h-4 mr-1") %><%= t("providers.snaptrade.free_tier_warning") %>

-
+ <%= render "settings/providers/setup_steps", + steps: [ + t("providers.snaptrade.step_1_html").html_safe, + t("providers.snaptrade.step_2"), + t("providers.snaptrade.step_3"), + t("providers.snaptrade.step_4") + ] %> <% error_msg = local_assigns[:error_message] || @error_message %> <% if error_msg.present? %> -
-

<%= error_msg %>

-
+ <%= render DS::Alert.new(message: error_msg, variant: :error) %> <% end %> <% @@ -51,56 +45,49 @@ <% items = local_assigns[:snaptrade_items] || @snaptrade_items || Current.family.snaptrade_items.where.not(client_id: [nil, ""]) %> -
- <% if items&.any? %> - <% item = items.first %> - <% if item.user_registered? %> -
- -
-
-

- <%= 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 %> -

-
- - <%= 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") %> -

- -
- <%= icon "loader-2", class: "w-4 h-4 animate-spin" %> - <%= t("providers.snaptrade.loading_connections") %> -
- -
-
-
-
- <% else %> -
-
-

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

-
- <% end %> - <% else %> -
-
-

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

+ <% if items&.any? %> + <% item = items.first %> + <% unless item.user_registered? %> +
+ +

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

<% end %> -
+ <% end %> + + <% if items&.any? && items.first.user_registered? %> + <% item = items.first %> +
+
+ +
+

+ <%= 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 %> +

+
+ + <%= t("providers.snaptrade.manage_connections") %> + <%= icon "chevron-right", class: "w-3 h-3 transition-transform group-open:rotate-90" %> + +
+ +
+
+ <%= icon "loader-2", class: "w-4 h-4 animate-spin" %> + <%= t("providers.snaptrade.loading_connections") %> +
+ +
+
+
+
+
+ <% end %>
diff --git a/app/views/settings/providers/_sophtron_panel.html.erb b/app/views/settings/providers/_sophtron_panel.html.erb index 878d4dd3f..e17c8a816 100644 --- a/app/views/settings/providers/_sophtron_panel.html.erb +++ b/app/views/settings/providers/_sophtron_panel.html.erb @@ -1,25 +1,14 @@
-
-

<%= t("sophtron_items.sophtron_panel.setup_instructions_title") %>

-
    -
  1. <%= t("sophtron_items.sophtron_panel.setup_instructions.step_1_html", url: "https://www.sophtron.com") %>
  2. -
  3. <%= t("sophtron_items.sophtron_panel.setup_instructions.step_2") %>
  4. -
  5. <%= t("sophtron_items.sophtron_panel.setup_instructions.step_3") %>
  6. -
- -

<%= t("sophtron_items.sophtron_panel.field_descriptions_title") %>

-
    -
  • <%= t("sophtron_items.sophtron_panel.field_descriptions.user_id_html") %>
  • -
  • <%= t("sophtron_items.sophtron_panel.field_descriptions.access_key_html") %>
  • -
  • <%= t("sophtron_items.sophtron_panel.field_descriptions.base_url_html") %>
  • -
-
+ <%= render "settings/providers/setup_steps", + steps: [ + t("sophtron_items.sophtron_panel.setup_instructions.step_1_html", url: "https://www.sophtron.com").html_safe, + t("sophtron_items.sophtron_panel.setup_instructions.step_2"), + t("sophtron_items.sophtron_panel.setup_instructions.step_3") + ] %> <% error_msg = local_assigns[:error_message] || @error_message %> <% if error_msg.present? %> -
-

<%= error_msg %>

-
+ <%= render DS::Alert.new(message: error_msg, variant: :error) %> <% end %> <% @@ -56,13 +45,4 @@
<% end %> -
- <% if Current.family.sophtron_items.any? %> -
-

<%= t("sophtron_items.sophtron_panel.status.configured_html", accounts_path: accounts_path) %>

- <% else %> -
-

<%= t("sophtron_items.sophtron_panel.status.not_configured") %>

- <% end %> -
-
\ No newline at end of file + diff --git a/app/views/settings/providers/_status_pill.html.erb b/app/views/settings/providers/_status_pill.html.erb new file mode 100644 index 000000000..1f46b9216 --- /dev/null +++ b/app/views/settings/providers/_status_pill.html.erb @@ -0,0 +1,7 @@ +<%# locals: (status:) %> +<% classes = status_pill_classes(status) %> +<% dot_class, pill_class = classes[:dot], classes[:pill] %> + + + <%= t("settings.providers.status.#{status}") %> + diff --git a/app/views/settings/providers/_sync_button.html.erb b/app/views/settings/providers/_sync_button.html.erb new file mode 100644 index 000000000..6e3df16ae --- /dev/null +++ b/app/views/settings/providers/_sync_button.html.erb @@ -0,0 +1,15 @@ +<%# locals: (provider_key:, last_synced_at: nil) %> +<% recently_synced = last_synced_at.present? && last_synced_at > 60.seconds.ago %> +<% button_label = recently_synced ? t("settings.providers.recently_synced") : t("settings.providers.sync_provider") %> +<%= render DS::Button.new( + variant: "icon", + size: "sm", + icon: "refresh-cw", + href: sync_provider_settings_providers_path(provider_key: provider_key), + method: :post, + disabled: recently_synced, + title: button_label, + aria: { label: button_label }, + class: "disabled:opacity-40 disabled:cursor-not-allowed", + form: { onclick: "event.stopPropagation()", class: "inline-flex" } + ) %> diff --git a/app/views/settings/providers/connect_form.html.erb b/app/views/settings/providers/connect_form.html.erb new file mode 100644 index 000000000..f4c6e6d3c --- /dev/null +++ b/app/views/settings/providers/connect_form.html.erb @@ -0,0 +1,21 @@ +<%= render DS::Dialog.new(frame: "drawer", responsive: true, auto_open: true) do |dialog| %> + <% provider_key = @panel_key || @provider_configuration&.provider_key&.to_s %> + <% dialog.with_header(custom_header: true) do %> + <%= render "settings/providers/drawer_header", provider_key: provider_key, title: @panel_title %> + <% end %> + <% dialog.with_body do %> + <% if @panel_partial %> + + <%= render "settings/providers/#{@panel_partial}" %> + + <% else %> + + <%= render "settings/providers/provider_form", configuration: @provider_configuration %> + + <% end %> + +

+ <%= t("settings.providers.drawer_trust_statement") %> +

+ <% end %> +<% end %> diff --git a/app/views/settings/providers/show.html.erb b/app/views/settings/providers/show.html.erb index 9f2b07c09..b78a19a96 100644 --- a/app/views/settings/providers/show.html.erb +++ b/app/views/settings/providers/show.html.erb @@ -1,94 +1,98 @@ -<%= content_for :page_title, "Sync Providers" %> +<%= content_for :page_title, t("settings.providers.bank_sync.page_title") %>
<% if @encryption_error %> -
-
- <%= icon("triangle-alert", class: "w-5 h-5 text-destructive shrink-0 mt-0.5") %> -
-

<%= t("settings.providers.encryption_error.title") %>

-

<%= t("settings.providers.encryption_error.message") %>

+ <%= render DS::Alert.new( + variant: :error, + message: safe_join([ + content_tag(:h2, t("settings.providers.encryption_error.title"), class: "font-medium"), + content_tag(:p, t("settings.providers.encryption_error.message"), class: "text-sm mt-1") + ]) + ) %> + <% else %> +
+

<%= t("settings.providers.bank_sync.lede") %>

+ <% if @connected.any? || @needs_attention.any? %> + <% sync_all_disabled = Current.family.last_sync_all_attempted_at.present? && Current.family.last_sync_all_attempted_at > 30.seconds.ago %> + <%= render DS::Link.new( + text: t("settings.providers.sync_all"), + icon: "refresh-cw", + variant: "outline", + href: sync_all_settings_providers_path, + method: :post, + title: sync_all_disabled ? t("settings.providers.sync_all_recently") : nil, + aria: { disabled: sync_all_disabled.to_s }, + class: sync_all_disabled ? "opacity-50 pointer-events-none" : nil + ) %> + <% end %> +
+ + <% all_connections = @needs_attention + @connected %> + + <% if all_connections.any? %> + <% if @health %> + <%= render "settings/providers/health_strip", + connected: @health[:connected], + needs_attention: @health[:needs_attention], + accounts_syncing: @health[:accounts_syncing], + last_synced_at: @health[:last_synced_at] %> + <% end %> + + <%= render "settings/providers/group_heading", + title: t("settings.providers.groups.your_connections"), + count: all_connections.size %> + +
+ <% all_connections.each do |entry| %> + <% auto_open = all_connections.size == 1 %> + <%= render "settings/providers/connection_row", entry: entry, open: auto_open %> + <% end %> +
+ <% end %> + + <% if @available.any? %> +
+ <%= render "settings/providers/search_filters" %> + +
+

+ <%= t("settings.providers.groups.available") %> + + · <%= @available.size %> + +

+
+ + + +
+ <% @available.each do |entry| %> + <% meta = Provider::Metadata.for(entry[:provider_key]) %> + <%= render Settings::ProviderCard.new( + provider_key: entry[:provider_key], + name: entry[:title], + tagline: t("settings.providers.taglines.#{entry[:provider_key]}", default: nil), + region: meta[:region], + kind: meta[:kind], + tier: meta[:tier], + maturity: meta[:maturity] || :stable, + logo_bg: meta[:logo_bg], + logo_text: meta[:logo_text] + ) %> + <% end %>
-
- <% else %> -
-

- Configure credentials for third-party sync providers. Settings configured here will override environment variables. -

-
- <% end %> - - <% unless @encryption_error %> - <% @provider_configurations.each do |config| %> - <%= settings_section title: config.provider_key.titleize, collapsible: true, open: false do %> - <%= render "settings/providers/provider_form", configuration: config %> - <% end %> - <% end %> - - <%# Providers below are hardcoded because they manage Family-scoped connections %> - <%# (via their own models like SimplefinItem, LunchflowItem, etc.) rather than global settings. %> - <%# They require custom UI for connection management, status display, and sync actions. %> - <%# The controller excludes them from @provider_configurations (see prepare_show_context). %> - - <%= settings_section title: "Lunch Flow", collapsible: true, open: false do %> - - <%= render "settings/providers/lunchflow_panel" %> - - <% end %> - - <%= settings_section title: "SimpleFIN", collapsible: true, open: false do %> - - <%= render "settings/providers/simplefin_panel" %> - - <% end %> - - <%= settings_section title: "Enable Banking (beta)", collapsible: true, open: false do %> - - <%= render "settings/providers/enable_banking_panel" %> - - <% end %> - - <%= settings_section title: "CoinStats (beta)", collapsible: true, open: false do %> - - <%= render "settings/providers/coinstats_panel" %> - - <% end %> - - <%= settings_section title: "Mercury (beta)", collapsible: true, open: false do %> - - <%= render "settings/providers/mercury_panel" %> - - <% end %> - - <%= settings_section title: "Coinbase (beta)", collapsible: true, open: false do %> - - <%= render "settings/providers/coinbase_panel" %> - - <% end %> - - <%= settings_section title: "Binance (beta)", collapsible: true, open: false do %> - - <%= render "settings/providers/binance_panel" %> - - <% end %> - - <%= settings_section title: "SnapTrade (beta)", collapsible: true, open: false, auto_open_param: "manage" do %> - - <%= render "settings/providers/snaptrade_panel" %> - - <% end %> - - <%= settings_section title: "Indexa Capital (alpha)", collapsible: true, open: false do %> - - <%= render "settings/providers/indexa_capital_panel" %> - - <% end %> - - <%= settings_section title: "Sophtron (alpha)", collapsible: true, open: false do %> - - <%= render "settings/providers/sophtron_panel" %> - + <% else %> + <%= render "settings/providers/group_heading", + title: t("settings.providers.groups.available"), + count: 0, + anchor: "available" %> +

<%= t("settings.providers.groups.empty_available") %>

<% end %> <% end %>
diff --git a/config/locales/views/coinstats_items/ca.yml b/config/locales/views/coinstats_items/ca.yml index 470fd3add..66140452a 100644 --- a/config/locales/views/coinstats_items/ca.yml +++ b/config/locales/views/coinstats_items/ca.yml @@ -50,8 +50,6 @@ ca: not_configured_step3_html: Segueix les instruccions de configuració proporcionades per completar la configuració del proveïdor not_configured_title: La connexió amb el proveïdor CoinStats no està configurada setup_instructions: "Instruccions de configuració:" - status_configured_html: Llest per utilitzar - status_not_configured: No configurat step1_html: Ves al Panell de l'API Pública de CoinStats per obtenir una clau API. step2: Introdueix la teva clau API a continuació i fes clic a Configura. step3_html: Després d'una connexió reeixida, ves a la pestanya Comptes per configurar les carteres de criptomonedes. diff --git a/config/locales/views/coinstats_items/de.yml b/config/locales/views/coinstats_items/de.yml index 56ce87bd8..d54f1e4fe 100644 --- a/config/locales/views/coinstats_items/de.yml +++ b/config/locales/views/coinstats_items/de.yml @@ -41,8 +41,6 @@ de: configure: Konfigurieren update_configuration: Neu konfigurieren default_name: CoinStats-Verbindung - status_configured_html: Bereit zur Nutzung - status_not_configured: Nicht konfiguriert coinstats_item: deletion_in_progress: Krypto-Wallet-Daten werden gelöscht… provider_name: CoinStats diff --git a/config/locales/views/coinstats_items/en.yml b/config/locales/views/coinstats_items/en.yml index ecd41109b..982aa8db3 100644 --- a/config/locales/views/coinstats_items/en.yml +++ b/config/locales/views/coinstats_items/en.yml @@ -55,8 +55,6 @@ en: configure: Configure update_configuration: Reconfigure default_name: CoinStats Connection - status_configured_html: Ready to use - status_not_configured: Not configured coinstats_item: deletion_in_progress: Crypto wallet data is being deleted… provider_name: CoinStats diff --git a/config/locales/views/coinstats_items/es.yml b/config/locales/views/coinstats_items/es.yml index 36744d53e..ca54f463b 100644 --- a/config/locales/views/coinstats_items/es.yml +++ b/config/locales/views/coinstats_items/es.yml @@ -41,8 +41,6 @@ es: configure: Configurar update_configuration: Reconfigurar default_name: Conexión de CoinStats - status_configured_html: Listo para usar - status_not_configured: No configurado coinstats_item: deletion_in_progress: Los datos de la cartera de criptomonedas se están eliminando… provider_name: CoinStats diff --git a/config/locales/views/coinstats_items/fr.yml b/config/locales/views/coinstats_items/fr.yml index b2b365510..84a89d621 100644 --- a/config/locales/views/coinstats_items/fr.yml +++ b/config/locales/views/coinstats_items/fr.yml @@ -55,8 +55,6 @@ fr: configure: Configurer update_configuration: Reconfigurer default_name: Connexion CoinStats - status_configured_html: Prêt à utiliser - status_not_configured: Non configuré coinstats_item: deletion_in_progress: Les données du portefeuille crypto sont en cours de suppression… provider_name: CoinStats diff --git a/config/locales/views/coinstats_items/hu.yml b/config/locales/views/coinstats_items/hu.yml index 8de8131a9..3a3dbad56 100644 --- a/config/locales/views/coinstats_items/hu.yml +++ b/config/locales/views/coinstats_items/hu.yml @@ -55,8 +55,6 @@ hu: configure: Konfigurálás update_configuration: Újrakonfigurálás default_name: CoinStats kapcsolat - status_configured_html: Használatra kész - status_not_configured: Nincs beállítva coinstats_item: deletion_in_progress: A kriptó pénztárca adatai törlés alatt… provider_name: CoinStats diff --git a/config/locales/views/coinstats_items/nl.yml b/config/locales/views/coinstats_items/nl.yml index 7c0918599..da6b19b55 100644 --- a/config/locales/views/coinstats_items/nl.yml +++ b/config/locales/views/coinstats_items/nl.yml @@ -43,8 +43,6 @@ nl: configure: Configureren update_configuration: Opnieuw configureren default_name: CoinStats Verbinding - status_configured_html: Klaar voor gebruik - status_not_configured: Niet geconfigureerd coinstats_item: deletion_in_progress: Crypto wallet gegevens worden verwijderd… provider_name: CoinStats diff --git a/config/locales/views/coinstats_items/pl.yml b/config/locales/views/coinstats_items/pl.yml index 968c9aa0b..15d0c66b4 100644 --- a/config/locales/views/coinstats_items/pl.yml +++ b/config/locales/views/coinstats_items/pl.yml @@ -45,8 +45,6 @@ pl: configure: Skonfiguruj update_configuration: Skonfiguruj ponownie default_name: Połączenie CoinStats - status_configured_html: Gotowe do użycia - status_not_configured: Nieskonfigurowane coinstats_item: deletion_in_progress: Trwa usuwanie danych portfela kryptowalutowego… provider_name: CoinStats diff --git a/config/locales/views/indexa_capital_items/de.yml b/config/locales/views/indexa_capital_items/de.yml index 09d0e2a22..e9c50dbfa 100644 --- a/config/locales/views/indexa_capital_items/de.yml +++ b/config/locales/views/indexa_capital_items/de.yml @@ -39,8 +39,6 @@ de: alternative_auth: "Oder nutzen Sie Benutzername/Passwort-Anmeldung..." save_button: "Konfiguration speichern" update_button: "Konfiguration aktualisieren" - status_configured_html: "Konfiguriert und einsatzbereit. Besuchen Sie die Registerkarte Konten, um Konten zu verwalten und einzurichten." - status_not_configured: "Nicht konfiguriert" fields: api_token: label: "API-Token" diff --git a/config/locales/views/indexa_capital_items/en.yml b/config/locales/views/indexa_capital_items/en.yml index a3a448b7c..774bca112 100644 --- a/config/locales/views/indexa_capital_items/en.yml +++ b/config/locales/views/indexa_capital_items/en.yml @@ -40,8 +40,6 @@ en: alternative_auth: "Or use username/password authentication instead..." save_button: "Save Configuration" update_button: "Update Configuration" - status_configured_html: "Configured and ready to use. Visit the Accounts tab to manage and set up accounts." - status_not_configured: "Not configured" fields: api_token: label: "API Token" diff --git a/config/locales/views/indexa_capital_items/es.yml b/config/locales/views/indexa_capital_items/es.yml index f63db32d8..82f9247a1 100644 --- a/config/locales/views/indexa_capital_items/es.yml +++ b/config/locales/views/indexa_capital_items/es.yml @@ -37,8 +37,6 @@ es: alternative_auth: "O usa la autenticación por usuario/contraseña en su lugar..." save_button: "Guardar configuración" update_button: "Actualizar configuración" - status_configured_html: "Configurado y listo para usar. Visita la pestaña de Cuentas para gestionar y configurar tus cuentas." - status_not_configured: "No configurado" fields: api_token: label: "Token de API" diff --git a/config/locales/views/indexa_capital_items/fr.yml b/config/locales/views/indexa_capital_items/fr.yml index 1e46d9bac..66dce0d18 100644 --- a/config/locales/views/indexa_capital_items/fr.yml +++ b/config/locales/views/indexa_capital_items/fr.yml @@ -40,8 +40,6 @@ fr: alternative_auth: "Ou utilisez plutôt l'authentification par nom d'utilisateur / mot de passe…" save_button: "Enregistrer la configuration" update_button: "Mettre à jour la configuration" - status_configured_html: "Configuré et prêt à l'emploi. Rendez-vous sur l'onglet Comptes pour gérer et configurer les comptes." - status_not_configured: "Non configuré" fields: api_token: label: "Jeton API" diff --git a/config/locales/views/indexa_capital_items/hu.yml b/config/locales/views/indexa_capital_items/hu.yml index 02ef29c59..be7bf5ff5 100644 --- a/config/locales/views/indexa_capital_items/hu.yml +++ b/config/locales/views/indexa_capital_items/hu.yml @@ -37,8 +37,6 @@ hu: alternative_auth: "Vagy használj felhasználónév/jelszó hitelesítést helyette..." save_button: "Konfiguráció mentése" update_button: "Konfiguráció frissítése" - status_configured_html: "Beállítva és használatra kész. A számlák kezeléséhez és beállításához látogass el a Számlák lapra." - status_not_configured: "Nincs beállítva" fields: api_token: label: "API token" diff --git a/config/locales/views/indexa_capital_items/pl.yml b/config/locales/views/indexa_capital_items/pl.yml index abab63caa..4ae96d462 100644 --- a/config/locales/views/indexa_capital_items/pl.yml +++ b/config/locales/views/indexa_capital_items/pl.yml @@ -39,8 +39,6 @@ pl: alternative_auth: Lub użyj uwierzytelniania loginem i hasłem... save_button: Zapisz konfigurację update_button: Zaktualizuj konfigurację - status_configured_html: Skonfigurowano i gotowe do użycia. Przejdź do zakładki Konta, aby zarządzać kontami i je konfigurować. - status_not_configured: Nie skonfigurowano fields: api_token: label: Token API diff --git a/config/locales/views/mercury_items/en.yml b/config/locales/views/mercury_items/en.yml index f3916fae5..2625bc40f 100644 --- a/config/locales/views/mercury_items/en.yml +++ b/config/locales/views/mercury_items/en.yml @@ -44,11 +44,9 @@ en: total: Total unlinked: Unlinked provider_panel: - accounts_link: Accounts add_connection: Add Mercury connection base_url_label: Base URL (optional) base_url_placeholder: https://api.mercury.com/api/v1 (default) - configured_html: "Configured and ready to use. Visit the %{accounts_link} tab to manage and set up accounts." connection_name_label: Connection name connection_name_placeholder: Business checking default_connection_name: Mercury Connection @@ -60,7 +58,6 @@ en: sign_in_html: "Visit %{link} and log in to the account you want to connect" whitelist_ip_html: "Important: Add your server's IP address to the token's whitelist" keep_token_placeholder: Leave blank to keep the current token - not_configured: Not configured sandbox_note_html: "Use a separate named connection for each Mercury login/API token you want to sync. For sandbox testing, use https://api-sandbox.mercury.com/api/v1 as the Base URL. Mercury requires IP whitelisting - make sure to add your IP in the Mercury dashboard." setup_accounts: Set up accounts setup_title: "Setup instructions:" diff --git a/config/locales/views/mercury_items/hu.yml b/config/locales/views/mercury_items/hu.yml index 75091c34c..8bf7271a7 100644 --- a/config/locales/views/mercury_items/hu.yml +++ b/config/locales/views/mercury_items/hu.yml @@ -44,11 +44,9 @@ hu: total: Összesen unlinked: Nincs összekapcsolva provider_panel: - accounts_link: Számlák add_connection: Mercury kapcsolat hozzáadása base_url_label: Alap URL (opcionális) base_url_placeholder: https://api.mercury.com/api/v1 (alapértelmezett) - configured_html: "Beállítva és használatra kész. A számlák kezeléséhez és beállításához látogass el a %{accounts_link} lapra." connection_name_label: Kapcsolat neve connection_name_placeholder: Üzleti folyószámla default_connection_name: Mercury kapcsolat @@ -60,7 +58,6 @@ hu: sign_in_html: "Látogass el a(z) %{link} oldalra, és lépj be az összekapcsolni kívánt fiókba" whitelist_ip_html: "Fontos: Add a szervered IP-címét a token engedélyezési listájához" keep_token_placeholder: Hagyd üresen az aktuális token megtartásához - not_configured: Nincs beállítva sandbox_note_html: "Minden Mercury bejelentkezéshez/API tokenhez használj külön elnevezett kapcsolatot. Sandbox teszteléshez használd a https://api-sandbox.mercury.com/api/v1 alap URL-t. A Mercury IP engedélyezési listát igényel — győződj meg róla, hogy hozzáadtad az IP-d a Mercury irányítópulton." setup_accounts: Számlák beállítása setup_title: "Beállítási utasítások:" diff --git a/config/locales/views/settings/de.yml b/config/locales/views/settings/de.yml index e8a64d97e..eb17807f2 100644 --- a/config/locales/views/settings/de.yml +++ b/config/locales/views/settings/de.yml @@ -167,8 +167,6 @@ de: syncing: Wird synchronisiert… sync: Synchronisieren disconnect_confirm: Bist du sicher, dass du diese Coinbase-Verbindung trennen möchtest? Deine synchronisierten Konten werden zu manuellen Konten. - status_connected: Coinbase ist verbunden und synchronisiert deine Krypto-Bestände. - status_not_connected: Nicht verbunden. Gib deine API-Zugangsdaten oben ein, um zu starten. enable_banking_panel: callback_url_instruction: "Für die Callback-URL, verwende %{callback_url}." connection_error: Verbindungsfehler diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 79e7c69d3..f3753374a 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -176,7 +176,7 @@ en: whats_new_label: What's new api_keys_label: API Key appearance_label: Appearance - bank_sync_label: Bank Sync + bank_sync_label: Bank sync settings_nav_link_large: next: Next previous: Back @@ -186,11 +186,73 @@ en: choose_label: (optional) change: Change photo providers: - show: - coinbase_title: Coinbase + not_authorized: Not authorized + bank_sync: + page_title: Bank sync + lede: Connect external accounts so transactions, balances and holdings flow into Sure automatically. + status: + ok: Connected + warn: Action needed + err: Error + off: Not configured + maturity: + beta: Beta + alpha: Alpha + drawer_trust_statement: "Read-only access. Sure can never move money, and your credentials are stored encrypted." + setup_steps: + eyebrow: Setup + need_help: "Need help?" + connect: Connect + groups: + your_connections: Your connections + available: Available + empty_available: All available providers are connected. + health_strip: + connected: connected + needs_attention: needs attention + accounts_syncing: accounts syncing + last_synced: Last synced %{time} ago + meta: + sync_error: Sync error + no_recent_sync: Sync overdue + registration_needed: Registration needed + reconsent_required: Re-consent required + reconsent_needed: + one: Re-consent needed in 1 day + other: Re-consent needed in %{count} days + last_synced: Synced %{time} ago + sync_all: Sync all + sync_all_in_progress: Syncing all connected providers… + sync_all_recently: Sync already in progress. Try again in a moment. + sync_provider: Sync now + sync_provider_in_progress: Sync started. + recently_synced: Synced recently. Try again in a moment. + taglines: + simplefin: Connect US bank accounts via the open SimpleFIN protocol. + lunchflow: Connect 20k+ banks from 40+ countries (UK, EU, USA and more!) + enable_banking: Sync European bank accounts via PSD2 open banking. + coinstats: Track your entire crypto portfolio across wallets and exchanges. + mercury: Sync your Mercury business banking accounts automatically. + coinbase: Import your Coinbase crypto holdings and track performance. + binance: Sync your Binance spot balances using a read-only API key. + snaptrade: Connect brokerage accounts via the SnapTrade aggregation network. + indexa_capital: Track your Indexa Capital automated investment portfolio. + sophtron: Connect US & Canadian banks and utilities. + plaid: Connect thousands of US financial institutions via Plaid. + plaid_eu: Connect European financial institutions via Plaid (PSD2 / Open Banking). + search_filters: + aria_label: Search providers + placeholder: Search providers + chips: + all: All + bank: Banks + crypto: Crypto + investment: Investments + empty_filter: No providers match your filter. + clear_filter: Clear filters encryption_error: - title: Encryption Configuration Required - message: Active Record encryption keys are not configured. Please ensure the encryption credentials (active_record_encryption.primary_key, active_record_encryption.deterministic_key, and active_record_encryption.key_derivation_salt) are properly set up in your Rails credentials or environment variables before using sync providers. + title: Encryption keys missing + message: "Bank sync needs Active Record encryption configured. Set primary_key, deterministic_key and key_derivation_salt in your Rails credentials or environment variables." coinbase_panel: setup_instructions: "To connect Coinbase:" step1_html: Go to Coinbase API Settings @@ -204,15 +266,14 @@ en: syncing: Syncing... sync: Sync disconnect_confirm: Are you sure you want to disconnect this Coinbase connection? Your synced accounts will become manual accounts. - status_connected: Coinbase is connected and syncing your crypto holdings. - status_not_connected: Not connected. Enter your API credentials above to get started. binance_panel: setup_instructions: "To connect Binance, create a read-only API key:" step1_html: 'Go to Binance API Management' step2: "Create a new API key with Enable Reading permission only" step3: "Paste your API Key and Secret below" - no_withdraw_warning: "Warning: do NOT enable withdrawal permissions" - ip_hint_title: "IP Whitelisting Required" + no_withdraw_title: "Read-only key only" + no_withdraw_body: "Don't enable withdrawal permissions when creating your Binance API key. Sure only needs read access." + ip_hint_title: "IP whitelisting required" ip_hint_body: "Add the app server's egress IP to the Binance API Key whitelist:" ip_hint_contact_admin: "Contact your administrator to obtain the app server's egress IP address." api_key_label: API Key @@ -223,8 +284,25 @@ en: syncing: Syncing... sync: Sync disconnect_confirm: "Are you sure you want to disconnect Binance?" - status_connected: Binance connected - status_not_connected: Binance not connected enable_banking_panel: callback_url_instruction: "For the callback URL, use %{callback_url}." connection_error: Connection Error + step_1_html: "Go to %{link} and grab your developer credentials." + step_2: "Pick your country and paste the Application ID + Client Certificate below." + step_3: "Save, then use Add Connection to link your bank." + lunchflow_panel: + step_1_html: "Go to %{link} and create an API key." + step_2: "Paste your key below and connect." + step_3: "Then head to Accounts to link your synced accounts." + simplefin_panel: + step_1_html: "Go to %{link} for a one-time setup token." + step_2: "Paste the token below and connect." + step_3: "Then head to Accounts to link your synced accounts." + plaid_panel: + step_1_html: "Open the %{link} and copy your Client ID and Secret Key." + step_2: "Pick an environment. Use sandbox for testing and production for real accounts." + step_3: "Paste your credentials below and connect." + plaid_eu_panel: + step_1_html: "Open the %{link} and copy your EU Client ID and Secret Key." + not_found: Provider not found. + sync_provider_no_items: No connections available to sync. diff --git a/config/locales/views/settings/es.yml b/config/locales/views/settings/es.yml index b9346a547..e8cf7fb0c 100644 --- a/config/locales/views/settings/es.yml +++ b/config/locales/views/settings/es.yml @@ -168,8 +168,6 @@ es: syncing: Sincronizando... sync: Sincronizar disconnect_confirm: ¿Estás seguro de que deseas desconectar esta conexión de Coinbase? Tus cuentas sincronizadas pasarán a ser cuentas manuales. - status_connected: Coinbase está conectado y sincronizando tus activos de criptomonedas. - status_not_connected: No conectado. Introduce tus credenciales de API arriba para comenzar. enable_banking_panel: callback_url_instruction: "Para la URL de retorno (callback), utiliza %{callback_url}." connection_error: Error de conexión \ No newline at end of file diff --git a/config/locales/views/settings/fr.yml b/config/locales/views/settings/fr.yml index e3179c63f..a3cb0aec7 100644 --- a/config/locales/views/settings/fr.yml +++ b/config/locales/views/settings/fr.yml @@ -202,8 +202,6 @@ fr: syncing: Synchronisation… sync: Synchroniser disconnect_confirm: Êtes-vous sûr(e) de vouloir déconnecter cette connexion Coinbase ? Vos comptes synchronisés deviendront des comptes manuels. - status_connected: Coinbase est connecté et synchronise vos avoirs en crypto. - status_not_connected: Non connecté. Saisissez vos identifiants API ci-dessus pour commencer. binance_panel: setup_instructions: "Pour connecter Binance, créez une clé API en lecture seule :" step1_html: 'Allez dans la Gestion des API Binance' @@ -221,8 +219,6 @@ fr: syncing: Synchronisation… sync: Synchroniser disconnect_confirm: "Êtes-vous sûr(e) de vouloir déconnecter Binance ?" - status_connected: Binance connecté - status_not_connected: Binance non connecté enable_banking_panel: callback_url_instruction: "Pour l'URL de rappel, utilisez %{callback_url}." connection_error: Erreur de connexion diff --git a/config/locales/views/settings/hu.yml b/config/locales/views/settings/hu.yml index ad687fdff..4d4d3dc91 100644 --- a/config/locales/views/settings/hu.yml +++ b/config/locales/views/settings/hu.yml @@ -202,8 +202,6 @@ hu: syncing: Szinkronizálás... sync: Szinkronizálás disconnect_confirm: Biztosan le szeretnéd választani ezt a Coinbase-kapcsolatot? A szinkronizált számlák manuális számlákká válnak. - status_connected: A Coinbase csatlakoztatva van, és szinkronizálja a kriptovaluta-állományodat. - status_not_connected: Nincs csatlakoztatva. Az induláshoz add meg az API-hitelesítő adataidat fent. binance_panel: setup_instructions: "A Binance csatlakoztatásához hozz létre egy csak olvasási jogosultsággal rendelkező API-kulcsot:" step1_html: 'Nyisd meg a Binance API-kezelőjét' @@ -221,8 +219,6 @@ hu: syncing: Szinkronizálás... sync: Szinkronizálás disconnect_confirm: "Biztosan le szeretnéd választani a Binance-t?" - status_connected: A Binance csatlakoztatva van - status_not_connected: A Binance nincs csatlakoztatva enable_banking_panel: callback_url_instruction: "A visszahívási URL-hez használd a következőt: %{callback_url}." connection_error: Kapcsolódási hiba diff --git a/config/locales/views/settings/pl.yml b/config/locales/views/settings/pl.yml index 9962be6a7..8acfe1772 100644 --- a/config/locales/views/settings/pl.yml +++ b/config/locales/views/settings/pl.yml @@ -185,8 +185,6 @@ pl: syncing: Synchronizacja... sync: Synchronizuj disconnect_confirm: Czy na pewno chcesz odłączyć to połączenie Coinbase? Twoje zsynchronizowane konta staną się kontami ręcznymi. - status_connected: Coinbase jest połączony i synchronizuje Twoje zasoby kryptowalutowe. - status_not_connected: Brak połączenia. Wprowadź powyżej dane API, aby rozpocząć. enable_banking_panel: callback_url_instruction: Dla URL callback użyj %{callback_url}. connection_error: Błąd połączenia diff --git a/config/locales/views/settings/pt-BR.yml b/config/locales/views/settings/pt-BR.yml index 26fa9fa13..65962871b 100644 --- a/config/locales/views/settings/pt-BR.yml +++ b/config/locales/views/settings/pt-BR.yml @@ -186,8 +186,6 @@ pt-BR: syncing: Sincronizando... sync: Sincronizar disconnect_confirm: Tem certeza de que deseja desconectar esta conexão com a Coinbase? Suas contas sincronizadas se tornarão contas manuais. - status_connected: A Coinbase está conectada e sincronizando seus ativos em criptomoedas. - status_not_connected: Não conectado. Insira suas credenciais de API acima para começar. enable_banking_panel: callback_url_instruction: "Para a URL de retorno de chamada, use %{callback_url}." connection_error: Erro de conexão diff --git a/config/locales/views/snaptrade_items/de.yml b/config/locales/views/snaptrade_items/de.yml index c0f0cd22c..6ab8c28cf 100644 --- a/config/locales/views/snaptrade_items/de.yml +++ b/config/locales/views/snaptrade_items/de.yml @@ -134,8 +134,6 @@ de: one: "%{count} muss eingerichtet werden" other: "%{count} müssen eingerichtet werden" status_ready: "Bereit zum Verbinden von Brokern" - status_needs_registration: "Zugangsdaten gespeichert. Gehen Sie zur Konten-Seite, um Broker zu verbinden." - status_not_configured: "Nicht konfiguriert" setup_accounts_button: "Konten einrichten" connect_button: "Broker verbinden" connected_brokerages: "Verbunden:" diff --git a/config/locales/views/snaptrade_items/en.yml b/config/locales/views/snaptrade_items/en.yml index e9cdfd250..053978a4b 100644 --- a/config/locales/views/snaptrade_items/en.yml +++ b/config/locales/views/snaptrade_items/en.yml @@ -117,7 +117,7 @@ en: step_2: "Copy your Client ID and Consumer Key from the dashboard" step_3: "Enter your credentials below and click Save" step_4: "Go to the Accounts page and use 'Connect another brokerage' to link your investment accounts" - free_tier_warning: "Free tier includes 5 brokerage connections. Additional connections require a paid SnapTrade plan." + free_tier_warning: "SnapTrade's free tier covers 5 brokerage connections. Upgrade on SnapTrade for more." client_id_label: "Client ID" client_id_placeholder: "Enter your SnapTrade Client ID" client_id_update_placeholder: "Enter new Client ID to update" @@ -129,17 +129,15 @@ en: status_connected: one: "%{count} account from SnapTrade" other: "%{count} accounts from SnapTrade" + status_needs_registration: "Credentials saved. Finish setup to connect a brokerage." needs_setup: one: "%{count} needs setup" other: "%{count} need setup" status_ready: "Ready to connect brokerages" - status_needs_registration: "Credentials saved. Go to Accounts page to connect brokerages." - status_not_configured: "Not configured" 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: diff --git a/config/locales/views/snaptrade_items/es.yml b/config/locales/views/snaptrade_items/es.yml index 47a6ca609..db087fff9 100644 --- a/config/locales/views/snaptrade_items/es.yml +++ b/config/locales/views/snaptrade_items/es.yml @@ -134,8 +134,6 @@ es: one: "%{count} necesita configuración" other: "%{count} necesitan configuración" status_ready: "Listo para conectar brókers" - status_needs_registration: "Credenciales guardadas. Ve a la página de Cuentas para conectar brókers." - status_not_configured: "No configurado" setup_accounts_button: "Configurar cuentas" connect_button: "Conectar bróker" connected_brokerages: "Conectados:" diff --git a/config/locales/views/snaptrade_items/fr.yml b/config/locales/views/snaptrade_items/fr.yml index 91a28e5ba..65183408e 100644 --- a/config/locales/views/snaptrade_items/fr.yml +++ b/config/locales/views/snaptrade_items/fr.yml @@ -134,8 +134,6 @@ fr: one: "%{count} à configurer" other: "%{count} à configurer" status_ready: "Prêt à connecter des courtiers" - status_needs_registration: "Identifiants enregistrés. Rendez-vous sur la page Comptes pour connecter des courtiers." - status_not_configured: "Non configuré" setup_accounts_button: "Configurer les comptes" connect_button: "Connecter un courtier" connected_brokerages: "Connectés :" diff --git a/config/locales/views/snaptrade_items/hu.yml b/config/locales/views/snaptrade_items/hu.yml index fd85c5f75..424ba7ca3 100644 --- a/config/locales/views/snaptrade_items/hu.yml +++ b/config/locales/views/snaptrade_items/hu.yml @@ -134,8 +134,6 @@ hu: one: "%{count} beállítást igényel" other: "%{count} beállítást igényel" status_ready: "Készen áll brókercégek csatlakoztatásához" - status_needs_registration: "Hitelesítő adatok mentve. Menj a Számlák oldalra brókercégek csatlakoztatásához." - status_not_configured: "Nincs beállítva" setup_accounts_button: "Számlák beállítása" connect_button: "Brókercég csatlakoztatása" connected_brokerages: "Csatlakoztatva:" diff --git a/config/locales/views/snaptrade_items/pl.yml b/config/locales/views/snaptrade_items/pl.yml index ba3eb7058..1f45a3aa1 100644 --- a/config/locales/views/snaptrade_items/pl.yml +++ b/config/locales/views/snaptrade_items/pl.yml @@ -145,8 +145,6 @@ pl: many: "%{count} wymaga konfiguracji" other: "%{count} wymaga konfiguracji" status_ready: Gotowe do połączenia z biurami maklerskimi - status_needs_registration: Dane uwierzytelniające zapisane. Przejdź do strony Konta, aby połączyć biura maklerskie. - status_not_configured: Nieskonfigurowane setup_accounts_button: Konfiguruj konta connect_button: Połącz biuro maklerskie connected_brokerages: 'Połączone:' diff --git a/config/locales/views/sophtron_items/en.yml b/config/locales/views/sophtron_items/en.yml index a74c76e77..5e52084bf 100644 --- a/config/locales/views/sophtron_items/en.yml +++ b/config/locales/views/sophtron_items/en.yml @@ -279,9 +279,6 @@ en: placeholder: "https://api.sophtron.com/api" save: "Save Configuration" update: "Update Configuration" - status: - configured_html: 'Configured and ready to use. Visit the Accounts tab to manage and set up accounts.' - not_configured: "Not configured" syncer: manual_sync_required: "Manual Sophtron sync is required for this institution; skipping those accounts during automated sync." importing_accounts: "Importing accounts from Sophtron..." diff --git a/config/locales/views/sophtron_items/hu.yml b/config/locales/views/sophtron_items/hu.yml index 7589414df..a4cb2d375 100644 --- a/config/locales/views/sophtron_items/hu.yml +++ b/config/locales/views/sophtron_items/hu.yml @@ -233,9 +233,6 @@ hu: placeholder: "https://api.sophtron.com/v2" save: "Konfiguráció mentése" update: "Konfiguráció frissítése" - status: - configured_html: "Beállítva és használatra kész. A számlák kezeléséhez és beállításához látogass el a Számlák lapra." - not_configured: "Nincs beállítva" syncer: manual_sync_required: "A kézi Sophtron szinkronizálás engedélyezve van; automatikus szinkronizálás kihagyva." importing_accounts: "Számlák importálása a Sophtron-ból..." diff --git a/config/routes.rb b/config/routes.rb index b143c64d5..3a59f340b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -205,8 +205,14 @@ Rails.application.routes.draw do resource :ai_prompts, only: :show resource :llm_usage, only: :show resource :guides, only: :show - resource :bank_sync, only: :show, controller: "bank_sync" - resource :providers, only: %i[show update] + get "bank_sync", to: redirect("/settings/providers", status: 301) + resource :providers, only: %i[show update] do + collection do + post :sync_all + post ":provider_key/sync", action: :sync, as: :sync_provider + get ":provider_key/connect_form", action: :connect_form, as: :connect_form + end + end end resource :subscription, only: %i[new show create] do diff --git a/db/migrate/20260510120000_add_last_sync_all_attempted_at_to_families.rb b/db/migrate/20260510120000_add_last_sync_all_attempted_at_to_families.rb new file mode 100644 index 000000000..0ab431f0a --- /dev/null +++ b/db/migrate/20260510120000_add_last_sync_all_attempted_at_to_families.rb @@ -0,0 +1,5 @@ +class AddLastSyncAllAttemptedAtToFamilies < ActiveRecord::Migration[7.2] + def change + add_column :families, :last_sync_all_attempted_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 641c5e640..b40605ab9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_05_08_130000) do +ActiveRecord::Schema[7.2].define(version: 2026_05_10_120000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -595,6 +595,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_08_130000) do t.string "assistant_type", default: "builtin", null: false t.string "default_account_sharing", default: "shared", null: false t.string "enabled_currencies", array: true + t.datetime "last_sync_all_attempted_at" t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying::text, 'private'::character varying::text])", name: "chk_families_default_account_sharing" t.check_constraint "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range" end diff --git a/test/controllers/settings/providers_controller_test.rb b/test/controllers/settings/providers_controller_test.rb index a7358e06e..00b98e893 100644 --- a/test/controllers/settings/providers_controller_test.rb +++ b/test/controllers/settings/providers_controller_test.rb @@ -1,6 +1,8 @@ require "test_helper" class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest + include ActiveJob::TestHelper + setup do sign_in users(:family_admin) @@ -8,6 +10,12 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest Provider::Factory.ensure_adapters_loaded end + test "GET /settings/bank_sync redirects permanently to /settings/providers" do + get "/settings/bank_sync" + assert_redirected_to "/settings/providers" + assert_equal 301, response.status + end + test "can access when self hosting is disabled (managed mode)" do Rails.configuration.stubs(:app_mode).returns("managed".inquiry) get settings_providers_url @@ -298,6 +306,55 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest end end + test "POST sync_all enqueues SyncAllProvidersJob" do + SimplefinItem.create!( + family: families(:dylan_family), + name: "Test SimpleFIN Sync All", + access_url: "https://bridge.simplefin.org/simplefin/access" + ) + families(:dylan_family).update_column(:last_sync_all_attempted_at, nil) + + assert_enqueued_with(job: SyncAllProvidersJob) do + post sync_all_settings_providers_path + end + + assert_redirected_to settings_providers_path + + follow_redirect! + assert_response :success + assert_match(/Syncing all connected providers/i, response.body) + end + + test "POST sync_all respects recent sync throttle" do + families(:dylan_family).update_column(:last_sync_all_attempted_at, Time.current) + + assert_no_enqueued_jobs only: SyncAllProvidersJob do + post sync_all_settings_providers_path + end + + assert_redirected_to settings_providers_path + assert_equal I18n.t("settings.providers.sync_all_recently"), flash[:notice] + end + + test "POST sync for simplefin without an active Simplefin sync enqueues SyncJob" do + item = SimplefinItem.create!( + family: families(:dylan_family), + name: "Test SimpleFIN Per Row Sync", + access_url: "https://bridge.simplefin.org/simplefin/access" + ) + Sync.where(syncable_type: "SimplefinItem", syncable_id: item.id).delete_all + + assert_enqueued_jobs 1, only: SyncJob do + post sync_provider_settings_providers_path(provider_key: "simplefin") + end + + assert_redirected_to settings_providers_path + + follow_redirect! + assert_response :success + assert_match(/Sync started/i, response.body) + end + test "non-admin users cannot update providers" do with_self_hosting do sign_in users(:family_member) @@ -306,7 +363,7 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest setting: { plaid_client_id: "test" } } - assert_redirected_to settings_providers_path + assert_redirected_to root_path assert_equal "Not authorized", flash[:alert] # Value should not have changed diff --git a/test/models/family/syncer_test.rb b/test/models/family/syncer_test.rb index baada992e..48ed9ffb2 100644 --- a/test/models/family/syncer_test.rb +++ b/test/models/family/syncer_test.rb @@ -5,11 +5,12 @@ class Family::SyncerTest < ActiveSupport::TestCase @family = families(:dylan_family) end - test "syncs plaid items and manual accounts" do + test "syncs provider items and manual accounts" do family_sync = syncs(:family) manual_accounts_count = @family.accounts.manual.count - items_count = @family.plaid_items.count + plaid_items_count = @family.plaid_items.syncable.count + binance_items_count = @family.binance_items.syncable.count syncer = Family::Syncer.new(@family) @@ -19,9 +20,14 @@ class Family::SyncerTest < ActiveSupport::TestCase .times(manual_accounts_count) PlaidItem.any_instance - .expects(:sync_later) - .with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil) - .times(items_count) + .expects(:sync_later) + .with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil) + .times(plaid_items_count) + + BinanceItem.any_instance + .expects(:sync_later) + .with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil) + .times(binance_items_count) syncer.perform_sync(family_sync) @@ -61,6 +67,7 @@ class Family::SyncerTest < ActiveSupport::TestCase LunchflowItem.any_instance.stubs(:sync_later) EnableBankingItem.any_instance.stubs(:sync_later) SophtronItem.any_instance.stubs(:sync_later) + BinanceItem.any_instance.stubs(:sync_later) syncer.perform_sync(family_sync) syncer.perform_post_sync diff --git a/test/system/settings/providers_test.rb b/test/system/settings/providers_test.rb new file mode 100644 index 000000000..549925ae0 --- /dev/null +++ b/test/system/settings/providers_test.rb @@ -0,0 +1,213 @@ +require "application_system_test_case" + +class Settings::ProvidersTest < ApplicationSystemTestCase + setup do + @user = users(:family_admin) + @family = families(:dylan_family) + login_as @user + end + + test "shows status pill on section header for a configured provider" do + SimplefinItem.create!(family: @family, name: "Test SimpleFIN", access_url: "https://bridge.simplefin.org/simplefin/access") + + visit settings_providers_path + + within("details", text: "SimpleFIN") do + assert_text "Connected" + end + end + + test "unconfigured SimpleFIN appears in Available with a connect affordance" do + visit settings_providers_path + + assert_no_selector "details", text: "SimpleFIN" + + within available_provider_cards_container do + assert_text "SimpleFIN" + assert_selector "a[data-turbo-frame='drawer']", text: "Connect" + end + end + + test "connected providers are grouped under Your connections in alphabetical title order" do + SimplefinItem.create!(family: @family, name: "Test SimpleFIN", access_url: "https://bridge.simplefin.org/simplefin/access") + + visit settings_providers_path + + titles = all("details").map { |d| d.find("summary h3", match: :first).text.squish } + assert_equal titles.sort_by(&:downcase), titles, "Connection panels should render alphabetically by title" + + connections_heading = page.find(:xpath, "//h2[contains(translate(normalize-space(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'your connections')]") + available_heading = page.find(:xpath, "//h2[contains(translate(normalize-space(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'available')]") + connections_y = connections_heading.native.location.y + available_y = available_heading.native.location.y + + assert_operator connections_y, :<, page.find("details", text: "SimpleFIN").native.location.y + assert_operator page.find("details", text: "SimpleFIN").native.location.y, :<, available_y + end + + test "expanding a section still works as expected" do + SimplefinItem.create!(family: @family, name: "Test SimpleFIN", access_url: "https://bridge.simplefin.org/simplefin/access") + + visit settings_providers_path + + assert_selector "details:not([open])", text: "SimpleFIN" + + find("details", text: "SimpleFIN").find("summary").click + + assert_selector "details[open]", text: "SimpleFIN" + within("details[open]", text: "SimpleFIN") do + assert_text "Setup Token" + end + end + + test "groups providers into Your connections and Available with counts" do + SimplefinItem.create!(family: @family, name: "Test SimpleFIN", access_url: "https://bridge.simplefin.org/simplefin/access") + + visit settings_providers_path + + connections_heading = find(:xpath, "//h2[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'your connections')]") + normalized = connections_heading.text.squish + assert_match(/Your connections .*· \d+/i, normalized) + + connections_y = connections_heading.native.location.y + available_heading = find(:xpath, "//h2[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'available')]") + available_y = available_heading.native.location.y + simplefin_y = find("details", text: "SimpleFIN").native.location.y + + assert_operator connections_y, :<, simplefin_y, "Your connections heading should appear above SimpleFIN section" + assert_operator simplefin_y, :<, available_y, "SimpleFIN should appear above Available heading" + + available_grid_top = available_provider_cards_container.native.location.y + assert_operator available_y, :<, available_grid_top, "Available heading should appear above the card grid" + end + + test "action needed group is absent when no providers have issues" do + SimplefinItem.create!(family: @family, name: "Test SimpleFIN", access_url: "https://bridge.simplefin.org/simplefin/access") + + visit settings_providers_path + + assert_selector "h2", text: /\AYour connections/i + assert_no_selector "h2", text: /\AAction needed/i + end + + test "enable banking with expiring session appears in your connections and auto-opens" do + item = EnableBankingItem.new( + family: @family, + name: "Test Bank", + country_code: "DE", + application_id: "test-app-id", + session_id: "test-session", + session_expires_at: 5.days.from_now + ) + # Skip certificate validation for test purposes + item.save!(validate: false) + + visit settings_providers_path + + assert_selector "h2", text: /\AYour connections/i + + # Auto-expanded warning sections hide compact meta behind `group-open:hidden`; + # collapse once so the re-consent copy is visible again. + enable = find("details", text: /Enable Banking/) + enable.find("summary").click if enable.matches_selector?(":open") + + assert_selector "details:not([open])", text: /Enable Banking/ + assert_text "Re-consent needed in 5 days" + end + + test "search input filters provider cards by name" do + visit settings_providers_path + + find('[data-providers-filter-target="input"]').set("Coinbase") + + assert_selector "a[data-providers-filter-target='card']", text: /Coinbase/i + assert_no_selector "a[data-providers-filter-target='card']", text: /Binance/i + end + + test "kind chip narrows the grid to providers of that kind" do + visit settings_providers_path + + click_on "Crypto" + + assert_selector "a[data-providers-filter-target='card']", text: /Coinbase/i + assert_no_selector "a[data-providers-filter-target='card']", text: /SimpleFIN/i + end + + test "search shows the empty filter message when no provider matches" do + visit settings_providers_path + + find('[data-providers-filter-target="input"]').set("zzz_no_match_zzz") + + assert_selector '[data-providers-filter-target="empty"]', text: I18n.t("settings.providers.empty_filter") + assert_no_selector "a[data-providers-filter-target='card']", visible: true + end + + test "available providers render as a card grid" do + visit settings_providers_path + + within available_provider_cards_container do + assert_text "SimpleFIN" + assert_selector "a[data-turbo-frame='drawer']", minimum: 1 + end + end + + test "clicking a provider card opens the connect drawer" do + visit settings_providers_path + + within available_provider_cards_container do + find("a[data-turbo-frame='drawer']", text: "SimpleFIN").click + end + + assert_selector "dialog[open]" + assert_text "Setup Token" + end + + test "configured plaid_eu surfaces in Your connections instead of Available" do + Setting["plaid_eu_client_id"] = "test_eu_client" + Setting["plaid_eu_secret"] = "test_eu_secret" + + visit settings_providers_path + + assert_selector "details summary h3", text: "Plaid EU" + within available_provider_cards_container do + assert_no_text "Plaid EU" + end + end + + test "clear filters button resets search input and chip state" do + visit settings_providers_path + + find('[data-providers-filter-target="input"]').set("zzz_no_match_zzz") + assert_selector '[data-providers-filter-target="empty"]', visible: true + + click_on I18n.t("settings.providers.clear_filter") + + assert_no_selector '[data-providers-filter-target="empty"]', visible: true + assert_equal "", find('[data-providers-filter-target="input"]').value + assert_selector "a[data-providers-filter-target='card']", text: /SimpleFIN/i + end + + test "warn-state connection row carries warning outline class" do + item = EnableBankingItem.new( + family: @family, + name: "Test Bank", + country_code: "DE", + application_id: "test-app-id", + session_id: "test-session", + session_expires_at: 5.days.from_now + ) + item.save!(validate: false) + + visit settings_providers_path + + details = find("details", text: /Enable Banking/) + assert_includes details[:class], "border-warning/25" + end + + private + + # Card grid rendered after the `#available` group heading (following sibling div.grid) + def available_provider_cards_container + find("#available").find(:xpath, "following-sibling::div[contains(concat(' ', normalize-space(@class), ' '), ' grid ')]") + end +end diff --git a/test/system/settings_test.rb b/test/system/settings_test.rb index 099e55f6b..4aef39d0e 100644 --- a/test/system/settings_test.rb +++ b/test/system/settings_test.rb @@ -6,8 +6,12 @@ class SettingsTest < ApplicationSystemTestCase # Base settings available to all users @settings_links = [ - [ "Accounts", accounts_path ], - [ "Bank Sync", settings_bank_sync_path ], + [ "Accounts", accounts_path ] + ] + + @settings_links << [ "Bank sync", settings_providers_path ] if @user.admin? + + @settings_links += [ [ "Preferences", settings_preferences_path ], [ "Profile Info", settings_profile_path ], [ "Security", settings_security_path ], @@ -87,6 +91,7 @@ class SettingsTest < ApplicationSystemTestCase # Assert that admin-only settings are not present in the navigation assert_no_selector "li", text: "AI Prompts" assert_no_selector "li", text: "API Key" + assert_no_selector "li", text: "Bank sync" end end