mirror of
https://github.com/we-promise/sure.git
synced 2026-05-21 19:44:55 +00:00
feat(settings/providers): card grid for available providers with connect drawer
- Add Provider::Metadata registry with static display data (region, kind, tier, maturity, logo) for all 11 providers - Add Settings::ProviderCard ViewComponent rendering logo square, name, Beta/Alpha pill, meta line (region · type · tier), tagline, and Connect link - Add connect_form action + route (GET /settings/providers/:key/connect_form) that opens the existing panel partial or config form in a DS::Dialog drawer - Replace the Available accordion loop with a 2-column responsive card grid; empty state when all providers are connected - Fix layout override: use turbo_rails/frame layout for frame requests so the drawer response is not wrapped in the full settings layout (was causing Turbo to pick the empty outer drawer frame instead of the filled one) - Add SyncAllProvidersJob and last_sync_all_attempted_at migration (sync-all throttle support) - Unify Connected + Action needed into a single "Your connections" section; items with warn/err status auto-open - Fix Enable Banking grouping: items with expired sessions were returning :off (Available) instead of :warn (Your connections); gate now checks any? instead of any?(&:session_valid?) - Add reconsent_required locale key for fully-expired EB sessions - Surface Beta/Alpha maturity pills on connected provider accordion rows via new badge: param on settings_section helper - Add i18n taglines for all 11 providers; add connect and empty_available keys Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
29
app/components/settings/provider_card.html.erb
Normal file
29
app/components/settings/provider_card.html.erb
Normal file
@@ -0,0 +1,29 @@
|
||||
<div class="bg-container shadow-border-xs rounded-xl p-4 flex flex-col gap-3 hover:shadow-border-sm transition-shadow">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 <%= logo_bg %>">
|
||||
<span class="text-xs font-bold text-white"><%= logo_text %></span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-medium text-primary"><%= name %></span>
|
||||
<% if maturity_label %>
|
||||
<span class="text-xs font-medium px-1.5 py-0.5 rounded-full bg-alpha-black-50 text-secondary"><%= maturity_label %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if meta_line.present? %>
|
||||
<p class="text-xs text-subdued mt-0.5"><%= meta_line %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% if tagline.present? %>
|
||||
<p class="text-sm text-secondary grow"><%= tagline %></p>
|
||||
<% end %>
|
||||
<div class="flex justify-end">
|
||||
<%= link_to connect_path,
|
||||
class: "inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:text-primary/70 transition-colors",
|
||||
data: { turbo_frame: "drawer", turbo_prefetch: "false" } do %>
|
||||
<%= t("settings.providers.connect") %>
|
||||
<%= helpers.icon "arrow-right", class: "w-4 h-4" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
31
app/components/settings/provider_card.rb
Normal file
31
app/components/settings/provider_card.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
class Settings::ProviderCard < ApplicationComponent
|
||||
MATURITY_LABELS = { beta: "Beta", alpha: "Alpha" }.freeze
|
||||
|
||||
def initialize(provider_key:, name:, tagline: nil, region: nil, kind: nil, tier: nil,
|
||||
maturity: :stable, logo_bg: "bg-gray-500", logo_text: nil)
|
||||
@provider_key = provider_key
|
||||
@name = name
|
||||
@tagline = tagline
|
||||
@region = region
|
||||
@kind = kind
|
||||
@tier = tier
|
||||
@maturity = maturity.to_sym
|
||||
@logo_bg = logo_bg
|
||||
@logo_text = logo_text || name.first(2).upcase
|
||||
end
|
||||
|
||||
def maturity_label
|
||||
MATURITY_LABELS[@maturity]
|
||||
end
|
||||
|
||||
def meta_line
|
||||
[ @region, @kind, @tier ].compact.join(" · ")
|
||||
end
|
||||
|
||||
def connect_path
|
||||
helpers.connect_form_settings_providers_path(provider_key: @provider_key)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :provider_key, :name, :tagline, :region, :kind, :tier, :maturity, :logo_bg, :logo_text
|
||||
end
|
||||
@@ -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,54 @@ class Settings::ProvidersController < ApplicationController
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def sync_all
|
||||
family = Current.family
|
||||
|
||||
if family.last_sync_all_attempted_at.present? && family.last_sync_all_attempted_at > 30.seconds.ago
|
||||
return redirect_to settings_providers_path, notice: t("settings.providers.sync_all_recently")
|
||||
end
|
||||
|
||||
family.update!(last_sync_all_attempted_at: Time.current)
|
||||
SyncAllProvidersJob.perform_later(family.id)
|
||||
redirect_to settings_providers_path, notice: t("settings.providers.sync_all_in_progress")
|
||||
end
|
||||
|
||||
def sync
|
||||
provider_key = params[:provider_key]
|
||||
syncable_type = PANEL_SYNCABLE_TYPES[provider_key]
|
||||
return redirect_to settings_providers_path unless syncable_type
|
||||
|
||||
items = syncable_type.constantize.where(family: Current.family).syncable
|
||||
items.each { |item| item.sync_later unless item.syncing? }
|
||||
|
||||
redirect_to settings_providers_path, notice: t("settings.providers.sync_provider_in_progress")
|
||||
end
|
||||
|
||||
def connect_form
|
||||
provider_key = params[:provider_key]
|
||||
|
||||
panel = FAMILY_PANELS.find { |p| p[:key] == provider_key }
|
||||
if panel
|
||||
@panel_key = panel[:key]
|
||||
@panel_partial = panel[:partial]
|
||||
@panel_title = panel[:title]
|
||||
load_provider_items(provider_key)
|
||||
return render :connect_form
|
||||
end
|
||||
|
||||
Provider::Factory.ensure_adapters_loaded
|
||||
config = Provider::ConfigurationRegistry.all.find { |c| c.provider_key.to_s == provider_key }
|
||||
if config
|
||||
@panel_title = provider_key.titleize
|
||||
@provider_configuration = config
|
||||
return render :connect_form
|
||||
end
|
||||
|
||||
redirect_to settings_providers_path
|
||||
rescue ActiveRecord::Encryption::Errors::Configuration
|
||||
redirect_to settings_providers_path, alert: t("settings.providers.encryption_error.title")
|
||||
end
|
||||
|
||||
private
|
||||
def provider_params
|
||||
# Dynamically permit all provider configuration fields
|
||||
@@ -125,16 +173,16 @@ class Settings::ProvidersController < ApplicationController
|
||||
# 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 (beta)", turbo_id: "enable_banking", partial: "enable_banking_panel" },
|
||||
{ key: "coinstats", title: "CoinStats (beta)", turbo_id: "coinstats", partial: "coinstats_panel" },
|
||||
{ key: "mercury", title: "Mercury (beta)", turbo_id: "mercury", partial: "mercury_panel" },
|
||||
{ key: "coinbase", title: "Coinbase (beta)", turbo_id: "coinbase", partial: "coinbase_panel" },
|
||||
{ key: "binance", title: "Binance (beta)", turbo_id: "binance", partial: "binance_panel" },
|
||||
{ key: "snaptrade", title: "SnapTrade (beta)", turbo_id: "snaptrade", partial: "snaptrade_panel", auto_open: "manage" },
|
||||
{ key: "indexa_capital", title: "Indexa Capital (alpha)", turbo_id: "indexa_capital", partial: "indexa_capital_panel" },
|
||||
{ key: "sophtron", title: "Sophtron (alpha)", turbo_id: "sophtron", partial: "sophtron_panel" }
|
||||
{ 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
|
||||
@@ -153,6 +201,31 @@ class Settings::ProvidersController < ApplicationController
|
||||
"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 family-scoped panels, which have their own UI below)
|
||||
@@ -232,6 +305,7 @@ class Settings::ProvidersController < ApplicationController
|
||||
provider_key: config.provider_key.to_s,
|
||||
title: config.provider_key.to_s.titleize,
|
||||
configuration: config,
|
||||
maturity: Provider::Metadata.for(config.provider_key)[:maturity],
|
||||
summary: view_context.provider_summary(config.provider_key)
|
||||
}
|
||||
end
|
||||
@@ -243,6 +317,7 @@ class Settings::ProvidersController < ApplicationController
|
||||
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
|
||||
|
||||
@@ -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 },
|
||||
{ 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,9 @@ module SettingsHelper
|
||||
}
|
||||
end
|
||||
|
||||
def settings_section(title:, subtitle: nil, collapsible: false, open: true, auto_open_param: nil, status: nil, meta: 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, status: status, meta: meta }
|
||||
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 provider_summary(provider_key)
|
||||
@@ -64,7 +63,7 @@ module SettingsHelper
|
||||
return { status: :off } unless @lunchflow_items&.any?
|
||||
sync_based_summary(key)
|
||||
when "enable_banking"
|
||||
return { status: :off } unless @enable_banking_items&.any?(&:session_valid?)
|
||||
return { status: :off } unless @enable_banking_items&.any?
|
||||
enable_banking_summary
|
||||
when "coinstats"
|
||||
return { status: :off } unless @coinstats_items&.any?
|
||||
@@ -119,39 +118,49 @@ module SettingsHelper
|
||||
private
|
||||
def sync_based_summary(provider_key)
|
||||
health = @provider_sync_health&.dig(provider_key) || {}
|
||||
last_synced_at = health[:last_synced_at]
|
||||
|
||||
if health[:error]
|
||||
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 health[:last_synced_at].present?
|
||||
{ status: :ok, meta: t("settings.providers.meta.last_synced", time: time_ago_in_words(health[:last_synced_at])) }
|
||||
elsif last_synced_at.present?
|
||||
{ status: :ok, meta: t("settings.providers.meta.last_synced", time: time_ago_in_words(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") } if health[:error]
|
||||
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) }
|
||||
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") } if health[:stale]
|
||||
return { status: :warn, meta: t("settings.providers.meta.no_recent_sync"), last_synced_at: last_synced_at } if health[:stale]
|
||||
|
||||
if health[:last_synced_at].present?
|
||||
{ status: :ok, meta: t("settings.providers.meta.last_synced", time: time_ago_in_words(health[:last_synced_at])) }
|
||||
if last_synced_at.present?
|
||||
{ status: :ok, meta: t("settings.providers.meta.last_synced", time: time_ago_in_words(last_synced_at)), last_synced_at: last_synced_at }
|
||||
else
|
||||
{ status: :ok }
|
||||
{ status: :ok, last_synced_at: nil }
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
9
app/jobs/sync_all_providers_job.rb
Normal file
9
app/jobs/sync_all_providers_job.rb
Normal file
@@ -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(family_id)
|
||||
family.sync_later
|
||||
end
|
||||
end
|
||||
98
app/models/provider/metadata.rb
Normal file
98
app/models/provider/metadata.rb
Normal file
@@ -0,0 +1,98 @@
|
||||
class Provider
|
||||
module Metadata
|
||||
REGISTRY = {
|
||||
simplefin: {
|
||||
region: "US",
|
||||
kind: "Bank",
|
||||
tier: "Free",
|
||||
maturity: :stable,
|
||||
logo_bg: "bg-blue-600",
|
||||
logo_text: "SF"
|
||||
},
|
||||
lunchflow: {
|
||||
region: "US",
|
||||
kind: "Lunch",
|
||||
tier: "Free",
|
||||
maturity: :stable,
|
||||
logo_bg: "bg-orange-500",
|
||||
logo_text: "LF"
|
||||
},
|
||||
enable_banking: {
|
||||
region: "EU",
|
||||
kind: "Bank",
|
||||
tier: "Free",
|
||||
maturity: :beta,
|
||||
logo_bg: "bg-purple-600",
|
||||
logo_text: "EB"
|
||||
},
|
||||
coinstats: {
|
||||
region: "Global",
|
||||
kind: "Crypto",
|
||||
tier: "Free",
|
||||
maturity: :beta,
|
||||
logo_bg: "bg-yellow-500",
|
||||
logo_text: "CS"
|
||||
},
|
||||
mercury: {
|
||||
region: "US",
|
||||
kind: "Bank",
|
||||
tier: "Free",
|
||||
maturity: :beta,
|
||||
logo_bg: "bg-cyan-600",
|
||||
logo_text: "ME"
|
||||
},
|
||||
coinbase: {
|
||||
region: "Global",
|
||||
kind: "Crypto",
|
||||
tier: "Free",
|
||||
maturity: :beta,
|
||||
logo_bg: "bg-blue-500",
|
||||
logo_text: "CB"
|
||||
},
|
||||
binance: {
|
||||
region: "Global",
|
||||
kind: "Crypto",
|
||||
tier: "Free",
|
||||
maturity: :beta,
|
||||
logo_bg: "bg-yellow-400",
|
||||
logo_text: "BI"
|
||||
},
|
||||
snaptrade: {
|
||||
region: "US / CA",
|
||||
kind: "Investment",
|
||||
tier: "Free",
|
||||
maturity: :beta,
|
||||
logo_bg: "bg-green-600",
|
||||
logo_text: "ST"
|
||||
},
|
||||
indexa_capital: {
|
||||
region: "ES",
|
||||
kind: "Investment",
|
||||
tier: "Free",
|
||||
maturity: :alpha,
|
||||
logo_bg: "bg-red-600",
|
||||
logo_text: "IC"
|
||||
},
|
||||
sophtron: {
|
||||
region: "US",
|
||||
kind: "Bank",
|
||||
tier: "Free",
|
||||
maturity: :alpha,
|
||||
logo_bg: "bg-teal-600",
|
||||
logo_text: "SO"
|
||||
},
|
||||
plaid: {
|
||||
region: "US",
|
||||
kind: "Bank",
|
||||
tier: "Paid",
|
||||
maturity: :stable,
|
||||
logo_bg: "bg-indigo-600",
|
||||
logo_text: "PL"
|
||||
}
|
||||
}.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
|
||||
@@ -1,4 +1,4 @@
|
||||
<%# locals: (title:, subtitle: nil, content:, collapsible: false, open: true, auto_open_param: nil, status: nil, meta: nil) %>
|
||||
<%# locals: (title:, subtitle: nil, content:, collapsible: false, open: true, auto_open_param: nil, status: nil, meta: nil, actions: nil, badge: nil) %>
|
||||
<% if collapsible %>
|
||||
<details <%= "open" if open %>
|
||||
class="group bg-container shadow-border-xs rounded-xl p-4"
|
||||
@@ -7,7 +7,10 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<%= icon "chevron-right", class: "text-secondary group-open:transform group-open:rotate-90 transition-transform" %>
|
||||
<div>
|
||||
<h2 class="text-lg font-medium text-primary"><%= title %></h2>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<h2 class="text-lg font-medium text-primary"><%= title %></h2>
|
||||
<%= badge if badge.present? %>
|
||||
</div>
|
||||
<% if subtitle.present? %>
|
||||
<p class="text-secondary text-sm"><%= subtitle %></p>
|
||||
<% end %>
|
||||
@@ -19,6 +22,7 @@
|
||||
<span class="text-xs text-subdued"><%= meta %></span>
|
||||
<% end %>
|
||||
<%= render "settings/providers/status_pill", status: status %>
|
||||
<%= actions if actions.present? %>
|
||||
</div>
|
||||
<% end %>
|
||||
</summary>
|
||||
|
||||
10
app/views/settings/providers/_add_provider_cta.html.erb
Normal file
10
app/views/settings/providers/_add_provider_cta.html.erb
Normal file
@@ -0,0 +1,10 @@
|
||||
<div class="bg-container shadow-border-xs rounded-xl p-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="font-medium text-primary"><%= t("settings.providers.add_provider_cta.title") %></p>
|
||||
<p class="text-sm text-secondary mt-0.5"><%= t("settings.providers.add_provider_cta.body") %></p>
|
||||
</div>
|
||||
<a href="#available" class="inline-flex items-center gap-2 shrink-0 px-3 py-1.5 text-sm font-medium text-secondary hover:text-primary border border-secondary rounded-lg hover:border-primary transition-colors">
|
||||
<%= icon "plus", class: "w-4 h-4" %>
|
||||
<%= t("settings.providers.add_provider_cta.cta") %>
|
||||
</a>
|
||||
</div>
|
||||
@@ -1,5 +1,5 @@
|
||||
<%# locals: (title:, count: nil, description: nil) %>
|
||||
<div class="flex items-baseline justify-between gap-3 mt-2 mb-1 px-1">
|
||||
<%# locals: (title:, count: nil, description: nil, anchor: nil) %>
|
||||
<div id="<%= anchor %>" class="flex items-baseline justify-between gap-3 mt-2 mb-1 px-1">
|
||||
<h2 class="text-sm font-medium text-primary flex items-baseline gap-2">
|
||||
<%= title %>
|
||||
<% if count %>
|
||||
|
||||
10
app/views/settings/providers/_sync_button.html.erb
Normal file
10
app/views/settings/providers/_sync_button.html.erb
Normal file
@@ -0,0 +1,10 @@
|
||||
<%# locals: (provider_key:, last_synced_at: nil) %>
|
||||
<% recently_synced = last_synced_at.present? && last_synced_at > 60.seconds.ago %>
|
||||
<%= button_to sync_provider_settings_providers_path(provider_key: provider_key),
|
||||
method: :post,
|
||||
disabled: recently_synced,
|
||||
title: recently_synced ? t("settings.providers.recently_synced") : t("settings.providers.sync_provider"),
|
||||
class: "inline-flex items-center p-1 rounded text-secondary hover:text-primary hover:bg-alpha-black-50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed",
|
||||
form: { onclick: "event.stopPropagation()", class: "inline-flex" } do %>
|
||||
<%= icon "refresh-cw", class: "w-3.5 h-3.5" %>
|
||||
<% end %>
|
||||
14
app/views/settings/providers/connect_form.html.erb
Normal file
14
app/views/settings/providers/connect_form.html.erb
Normal file
@@ -0,0 +1,14 @@
|
||||
<%= render DS::Dialog.new(frame: "drawer", responsive: true, auto_open: true) do |dialog| %>
|
||||
<% dialog.with_header(title: @panel_title) %>
|
||||
<% dialog.with_body do %>
|
||||
<% if @panel_partial %>
|
||||
<turbo-frame id="<%= @panel_key %>-connect-form" target="_top">
|
||||
<%= render "settings/providers/#{@panel_partial}" %>
|
||||
</turbo-frame>
|
||||
<% else %>
|
||||
<turbo-frame id="config-connect-form" target="_top">
|
||||
<%= render "settings/providers/provider_form", configuration: @provider_configuration %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -1,4 +1,4 @@
|
||||
<%= content_for :page_title, "Sync Providers" %>
|
||||
<%= content_for :page_title, "Bank Sync" %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<% if @encryption_error %>
|
||||
@@ -12,31 +12,48 @@
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div>
|
||||
<p class="text-secondary mb-4">
|
||||
Configure credentials for third-party sync providers. Settings configured here will override environment variables.
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<p class="text-secondary">
|
||||
Connect external accounts so transactions, balances and holdings flow into Sure automatically.
|
||||
</p>
|
||||
<% 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 %>
|
||||
<%= button_to sync_all_settings_providers_path,
|
||||
method: :post,
|
||||
disabled: sync_all_disabled,
|
||||
title: sync_all_disabled ? t("settings.providers.sync_all_recently") : nil,
|
||||
class: "inline-flex items-center gap-2 shrink-0 px-3 py-1.5 text-sm font-medium text-secondary hover:text-primary border border-secondary rounded-lg hover:border-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed" do %>
|
||||
<%= icon "refresh-cw", class: "w-4 h-4" %>
|
||||
<%= t("settings.providers.sync_all") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= render Settings::HealthSummary.new(counts: @health_counts) %>
|
||||
|
||||
<%= render "settings/providers/group_heading",
|
||||
title: t("settings.providers.groups.connected"),
|
||||
count: @connected.size %>
|
||||
<% all_connections = @needs_attention + @connected %>
|
||||
|
||||
<% if @connected.empty? && @needs_attention.empty? %>
|
||||
<%= render "settings/providers/group_heading",
|
||||
title: t("settings.providers.groups.your_connections"),
|
||||
count: all_connections.size %>
|
||||
|
||||
<% if all_connections.empty? %>
|
||||
<p class="text-sm text-secondary px-1 py-2"><%= t("settings.providers.groups.empty_connected") %></p>
|
||||
<% end %>
|
||||
|
||||
<%# Auto-open when there is exactly one connected (and no action-needed) item. %>
|
||||
<% auto_open_only = @connected.size == 1 && @needs_attention.empty? %>
|
||||
<% @connected.each do |entry| %>
|
||||
<% all_connections.each do |entry| %>
|
||||
<% auto_open = [ :warn, :err ].include?(entry[:summary][:status]) || all_connections.size == 1 %>
|
||||
<% sync_action = entry[:partial].present? ? render("settings/providers/sync_button", provider_key: entry[:provider_key], last_synced_at: entry[:summary][:last_synced_at]) : nil %>
|
||||
<% maturity_label = Settings::ProviderCard::MATURITY_LABELS[entry[:maturity]] %>
|
||||
<% maturity_badge = maturity_label ? content_tag(:span, maturity_label, class: "text-xs font-medium px-1.5 py-0.5 rounded-full bg-alpha-black-50 text-secondary") : nil %>
|
||||
<%= settings_section title: entry[:title],
|
||||
collapsible: true,
|
||||
open: auto_open_only,
|
||||
open: auto_open,
|
||||
auto_open_param: entry[:auto_open_param],
|
||||
status: entry[:summary][:status],
|
||||
meta: entry[:summary][:meta] do %>
|
||||
meta: entry[:summary][:meta],
|
||||
actions: sync_action,
|
||||
badge: maturity_badge do %>
|
||||
<% if entry[:configuration] %>
|
||||
<%= render "settings/providers/provider_form", configuration: entry[:configuration] %>
|
||||
<% else %>
|
||||
@@ -47,48 +64,34 @@
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% unless @needs_attention.empty? %>
|
||||
<%= render "settings/providers/group_heading",
|
||||
title: t("settings.providers.groups.needs_attention"),
|
||||
count: @needs_attention.size %>
|
||||
|
||||
<% @needs_attention.each do |entry| %>
|
||||
<%= settings_section title: entry[:title],
|
||||
collapsible: true,
|
||||
open: true,
|
||||
auto_open_param: entry[:auto_open_param],
|
||||
status: entry[:summary][:status],
|
||||
meta: entry[:summary][:meta] do %>
|
||||
<% if entry[:configuration] %>
|
||||
<%= render "settings/providers/provider_form", configuration: entry[:configuration] %>
|
||||
<% else %>
|
||||
<turbo-frame id="<%= entry[:turbo_id] %>-providers-panel">
|
||||
<%= render "settings/providers/#{entry[:partial]}" %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% unless @available.empty? %>
|
||||
<%= render "settings/providers/add_provider_cta" %>
|
||||
<% end %>
|
||||
|
||||
<%= render "settings/providers/group_heading",
|
||||
title: t("settings.providers.groups.available"),
|
||||
count: @available.size %>
|
||||
count: @available.size,
|
||||
anchor: "available" %>
|
||||
|
||||
<% @available.each do |entry| %>
|
||||
<%= settings_section title: entry[:title],
|
||||
collapsible: true,
|
||||
open: false,
|
||||
auto_open_param: entry[:auto_open_param],
|
||||
status: entry[:summary][:status],
|
||||
meta: entry[:summary][:meta] do %>
|
||||
<% if entry[:configuration] %>
|
||||
<%= render "settings/providers/provider_form", configuration: entry[:configuration] %>
|
||||
<% else %>
|
||||
<turbo-frame id="<%= entry[:turbo_id] %>-providers-panel">
|
||||
<%= render "settings/providers/#{entry[:partial]}" %>
|
||||
</turbo-frame>
|
||||
<% if @available.empty? %>
|
||||
<p class="text-sm text-secondary px-1 py-2"><%= t("settings.providers.groups.empty_available") %></p>
|
||||
<% else %>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<% @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 %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -191,11 +191,14 @@ en:
|
||||
warn: Action needed
|
||||
err: Error
|
||||
off: Not configured
|
||||
connect: Connect
|
||||
groups:
|
||||
your_connections: Your connections
|
||||
connected: Connected
|
||||
needs_attention: Action needed
|
||||
available: Available
|
||||
empty_connected: Nothing connected yet — pick a provider below to get started.
|
||||
empty_available: All available providers are connected.
|
||||
health:
|
||||
connected: Connected
|
||||
needs_attention: Action needed
|
||||
@@ -205,10 +208,33 @@ en:
|
||||
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: Track school lunch account balances for your kids.
|
||||
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 bank accounts via the Sophtron aggregation network.
|
||||
plaid: Connect thousands of US financial institutions via Plaid.
|
||||
add_provider_cta:
|
||||
title: Add another provider
|
||||
body: Connect a new bank or data source to start syncing accounts.
|
||||
cta: Browse providers
|
||||
show:
|
||||
coinbase_title: Coinbase
|
||||
encryption_error:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddLastSyncAllAttemptedAtToFamilies < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :families, :last_sync_all_attempted_at, :datetime
|
||||
end
|
||||
end
|
||||
29
db/schema.rb
generated
29
db/schema.rb
generated
@@ -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_03_180000) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_05_08_120000) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -40,7 +40,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_03_180000) do
|
||||
t.index ["account_id"], name: "index_account_shares_on_account_id"
|
||||
t.index ["user_id", "include_in_finances"], name: "index_account_shares_on_user_id_and_include_in_finances"
|
||||
t.index ["user_id"], name: "index_account_shares_on_user_id"
|
||||
t.check_constraint "permission::text = ANY (ARRAY['full_control'::character varying::text, 'read_write'::character varying::text, 'read_only'::character varying::text])", name: "chk_account_shares_permission"
|
||||
t.check_constraint "permission::text = ANY (ARRAY['full_control'::character varying, 'read_write'::character varying, 'read_only'::character varying]::text[])", name: "chk_account_shares_permission"
|
||||
end
|
||||
|
||||
create_table "accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
@@ -595,7 +595,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_03_180000) 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.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying::text, 'private'::character varying::text])", name: "chk_families_default_account_sharing"
|
||||
t.datetime "last_sync_all_attempted_at"
|
||||
t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying, '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
|
||||
|
||||
@@ -1400,13 +1401,14 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_03_180000) do
|
||||
t.jsonb "institution_metadata"
|
||||
t.jsonb "raw_payload"
|
||||
t.jsonb "raw_transactions_payload"
|
||||
t.string "customer_id", null: false
|
||||
t.string "member_id", null: false
|
||||
t.string "customer_id"
|
||||
t.string "member_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "account_number_mask"
|
||||
t.index ["account_id"], name: "index_sophtron_accounts_on_account_id"
|
||||
t.index ["sophtron_item_id"], name: "index_sophtron_accounts_on_sophtron_item_id"
|
||||
t.index ["sophtron_item_id", "account_id"], name: "idx_unique_sophtron_accounts_per_item", unique: true
|
||||
t.index ["sophtron_item_id"], name: "index_sophtron_accounts_on_sophtron_item_id"
|
||||
end
|
||||
|
||||
create_table "sophtron_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
@@ -1428,8 +1430,21 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_03_180000) do
|
||||
t.string "base_url"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "customer_id"
|
||||
t.string "customer_name"
|
||||
t.jsonb "raw_customer_payload"
|
||||
t.string "user_institution_id"
|
||||
t.string "current_job_id"
|
||||
t.string "job_status"
|
||||
t.jsonb "raw_job_payload"
|
||||
t.string "last_connection_error"
|
||||
t.boolean "manual_sync", default: false, null: false
|
||||
t.uuid "current_job_sophtron_account_id"
|
||||
t.index ["current_job_sophtron_account_id"], name: "index_sophtron_items_on_current_job_sophtron_account_id"
|
||||
t.index ["customer_id"], name: "index_sophtron_items_on_customer_id"
|
||||
t.index ["family_id"], name: "index_sophtron_items_on_family_id"
|
||||
t.index ["status"], name: "index_sophtron_items_on_status"
|
||||
t.index ["user_institution_id"], name: "index_sophtron_items_on_user_institution_id"
|
||||
end
|
||||
|
||||
create_table "sso_audit_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
@@ -1648,9 +1663,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_03_180000) do
|
||||
t.datetime "last_used_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.check_constraint "sign_count >= 0", name: "chk_webauthn_credentials_sign_count_non_negative"
|
||||
t.index ["credential_id"], name: "index_webauthn_credentials_on_credential_id", unique: true
|
||||
t.index ["user_id"], name: "index_webauthn_credentials_on_user_id"
|
||||
t.check_constraint "sign_count >= 0", name: "chk_webauthn_credentials_sign_count_non_negative"
|
||||
end
|
||||
|
||||
add_foreign_key "account_providers", "accounts", on_delete: :cascade
|
||||
|
||||
@@ -49,22 +49,22 @@ class Settings::ProvidersTest < ApplicationSystemTestCase
|
||||
details.assert_text "Setup Token"
|
||||
end
|
||||
|
||||
test "groups providers into Connected and Available with counts" do
|
||||
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
|
||||
|
||||
connected_heading = find("h2", text: /\AConnected/)
|
||||
assert_match(/· 1\z/, connected_heading.text)
|
||||
connections_heading = find("h2", text: /\AYour connections/)
|
||||
assert_match(/· 1\z/, connections_heading.text)
|
||||
|
||||
available_heading = find("h2", text: /\AAvailable/)
|
||||
|
||||
connected_y = connected_heading.native.location.y
|
||||
available_y = available_heading.native.location.y
|
||||
simplefin_y = find("details", text: "SimpleFIN").native.location.y
|
||||
binance_y = find("details", text: "Binance").native.location.y
|
||||
connections_y = connections_heading.native.location.y
|
||||
available_y = available_heading.native.location.y
|
||||
simplefin_y = find("details", text: "SimpleFIN").native.location.y
|
||||
binance_y = find("details", text: "Binance").native.location.y
|
||||
|
||||
assert connected_y < simplefin_y, "Connected heading should appear above SimpleFIN section"
|
||||
assert connections_y < simplefin_y, "Your connections heading should appear above SimpleFIN section"
|
||||
assert simplefin_y < available_y, "SimpleFIN should appear above Available heading"
|
||||
assert available_y < binance_y, "Available heading should appear above Binance section"
|
||||
end
|
||||
@@ -85,10 +85,11 @@ class Settings::ProvidersTest < ApplicationSystemTestCase
|
||||
|
||||
visit settings_providers_path
|
||||
|
||||
assert_selector "h2", text: /\AYour connections/
|
||||
assert_no_selector "h2", text: /\AAction needed/
|
||||
end
|
||||
|
||||
test "enable banking with expiring session lands in action needed and auto-opens" do
|
||||
test "enable banking with expiring session appears in your connections and auto-opens" do
|
||||
item = EnableBankingItem.new(
|
||||
family: @family,
|
||||
name: "Test Bank",
|
||||
@@ -102,11 +103,72 @@ class Settings::ProvidersTest < ApplicationSystemTestCase
|
||||
|
||||
visit settings_providers_path
|
||||
|
||||
assert_selector "h2", text: /\AAction needed/
|
||||
assert_selector "h2", text: /\AYour connections/
|
||||
|
||||
# The Enable Banking section should be in the action-needed group and auto-opened
|
||||
# The Enable Banking section should be in the Your connections group and auto-opened
|
||||
within("details[open]", text: /Enable Banking/) do
|
||||
assert_text "Re-consent needed in 5 days"
|
||||
end
|
||||
end
|
||||
|
||||
test "sync all button enqueues SyncAllProvidersJob and shows flash" do
|
||||
SimplefinItem.create!(family: @family, name: "Test SimpleFIN", access_url: "https://bridge.simplefin.org/simplefin/access")
|
||||
|
||||
visit settings_providers_path
|
||||
|
||||
assert_enqueued_with(job: SyncAllProvidersJob) do
|
||||
click_on "Sync all"
|
||||
end
|
||||
|
||||
assert_text "Syncing all connected providers"
|
||||
end
|
||||
|
||||
test "per-row sync button enqueues sync for that provider and shows flash" 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
|
||||
find("button[title='Sync now']").click
|
||||
end
|
||||
|
||||
assert_text "Sync started"
|
||||
end
|
||||
|
||||
test "add provider CTA banner appears above available group when providers are connected" do
|
||||
SimplefinItem.create!(family: @family, name: "Test SimpleFIN", access_url: "https://bridge.simplefin.org/simplefin/access")
|
||||
|
||||
visit settings_providers_path
|
||||
|
||||
cta = find("a", text: "Browse providers")
|
||||
available_heading = find("h2", text: /\AAvailable/)
|
||||
|
||||
cta_y = cta.native.location.y
|
||||
available_y = available_heading.native.location.y
|
||||
|
||||
assert cta_y < available_y, "Add-provider CTA should appear above the Available heading"
|
||||
end
|
||||
|
||||
test "available providers render as a card grid" do
|
||||
visit settings_providers_path
|
||||
|
||||
# SimpleFIN is not connected, so it should appear in the card grid
|
||||
within "div.grid" do
|
||||
assert_text "SimpleFIN"
|
||||
assert_selector "a[data-turbo-frame='drawer']", minimum: 1
|
||||
end
|
||||
end
|
||||
|
||||
test "clicking a provider card connect link opens the connect drawer" do
|
||||
visit settings_providers_path
|
||||
|
||||
# Find and click the SimpleFIN card's Connect link
|
||||
within "div.grid" do
|
||||
find("a[data-turbo-frame='drawer']", text: /Connect/, match: :first).click
|
||||
end
|
||||
|
||||
# Drawer should open with the panel content
|
||||
assert_selector "dialog[open]"
|
||||
assert_text "Setup Token"
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user