Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 19 additions & 11 deletions app/controllers/accounts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@ def index
@manual_accounts = family.accounts
.listable_manual
.where(id: @accessible_account_ids)
.includes(:accountable, :account_providers, :plaid_account, :simplefin_account)
.includes(:accountable, :account_providers, :provider_accounts, :simplefin_account)
.order(:name)
@plaid_items = visible_provider_items(family.plaid_items.ordered.includes(:syncs, :plaid_accounts))
@simplefin_items = visible_provider_items(family.simplefin_items.ordered.includes(:syncs))
@lunchflow_items = visible_provider_items(family.lunchflow_items.ordered.includes(:syncs, :lunchflow_accounts))
@enable_banking_items = visible_provider_items(family.enable_banking_items.ordered.includes(:syncs))
Expand All @@ -22,6 +21,7 @@ def index
@snaptrade_items = visible_provider_items(family.snaptrade_items.ordered.includes(:syncs, :snaptrade_accounts))
@indexa_capital_items = visible_provider_items(family.indexa_capital_items.ordered.includes(:syncs, :indexa_capital_accounts))
@sophtron_items = visible_provider_items(family.sophtron_items.ordered.includes(:syncs, :sophtron_accounts))
@provider_connections = family.provider_connections.order(:created_at).includes(provider_accounts: :account)

# Build sync stats maps for all providers
build_sync_stats_maps
Expand All @@ -32,10 +32,11 @@ def index
end

def new
# Get all registered providers with any credentials configured
@provider_configs = Provider::Factory.registered_adapters.flat_map do |adapter_class|
adapter_class.connection_configs(family: family)
end

@provider_configs += Provider::ConnectionRegistry.all_connection_configs(family: family)
end

def sync_all
Expand Down Expand Up @@ -155,8 +156,15 @@ def unlink
# wasting a slot on reconnect.
@account.account_providers.destroy_all

# Remove Provider::Connection-framework link (Plaid, TrueLayer, etc.)
# The Provider::Account row is preserved (account_id nullified) so the
# connection's setup page shows it as available to relink. Use
# update_all to bypass association caches.
Provider::Account.where(account_id: @account.id).update_all(account_id: nil)
@account.association(:provider_account).reset

# Remove legacy system links (foreign keys)
@account.update!(plaid_account_id: nil, simplefin_account_id: nil)
@account.update!(simplefin_account_id: nil)

# Destroy the SimplefinAccount record so it doesn't cause stale account issues
# This is safe because:
Expand Down Expand Up @@ -189,6 +197,13 @@ def select_provider
family: family
)

eligible_keys = Provider::ConnectionRegistry.keys.select { |key|
Provider::ConnectionRegistry.adapter_for(key).supported_account_types.include?(account_type_name)
}
provider_configs += eligible_keys
.flat_map { |key| Provider::ConnectionRegistry.adapter_for(key).connection_configs(family: family) }
.uniq { |c| c[:key] }

# Build available providers list with paths resolved for this specific account
# Filter out providers that don't support linking to existing accounts
@available_providers = provider_configs.filter_map do |config|
Expand Down Expand Up @@ -271,13 +286,6 @@ def build_sync_stats_maps
@simplefin_duplicate_only_map[item.id] = false
end

# Plaid sync stats
@plaid_sync_stats_map = {}
@plaid_items.each do |item|
latest_sync = item.syncs.ordered.first
@plaid_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
end

# Lunchflow sync stats
@lunchflow_sync_stats_map = {}
@lunchflow_items.each do |item|
Expand Down
4 changes: 3 additions & 1 deletion app/controllers/api/v1/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ def reset_target_counts(family)
categories: family.categories.count,
tags: family.tags.count,
merchants: family.merchants.count,
plaid_items: family.plaid_items.count,
# Framework-managed bank/sync connections (Plaid, TrueLayer, ...).
# Legacy plaid_items / plaid_accounts were dropped in this branch.
provider_connections: family.provider_connections.count,
imports: family.imports.count,
budgets: family.budgets.count
}
Expand Down
13 changes: 12 additions & 1 deletion app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,18 @@ def pundit_user
before_action :set_default_chat
before_action :set_active_storage_url_options

helper_method :demo_config, :demo_host_match?, :show_demo_warning?
helper_method :demo_config, :demo_host_match?, :show_demo_warning?, :configured_host

# Host used to construct upstream-registered redirect URIs (TrueLayer Console,
# Plaid Dashboard, etc.). Prefers Rails.application.routes.default_url_options[:host]
# — set explicitly per environment — over request.host, which is subject to
# Host-header injection on misconfigured deploys. Both controllers (when
# building the redirect_uri sent at OAuth exchange) AND views (when showing
# the URL the admin pastes into the upstream dashboard) use this so the
# values cannot diverge.
def configured_host
Rails.application.routes.default_url_options[:host] || request.host
end

private
def accept_pending_invitation_for(user)
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/binance_items_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ def link_existing_account
return
end

if @account.account_providers.any? || @account.plaid_account_id.present? || @account.simplefin_account_id.present?
if @account.account_providers.any? || @account.simplefin_account_id.present?
alert_msg = t(".errors.only_manual")
if turbo_frame_request?
flash.now[:alert] = alert_msg
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/coinbase_items_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ def link_existing_account
end

# Guard: only manual accounts can be linked (no existing provider links)
if @account.account_providers.any? || @account.plaid_account_id.present? || @account.simplefin_account_id.present?
if @account.account_providers.any? || @account.simplefin_account_id.present?
flash[:alert] = t(".errors.only_manual")
if turbo_frame_request?
return render turbo_stream: Array(flash_notification_stream_items)
Expand Down
8 changes: 7 additions & 1 deletion app/controllers/concerns/accountable_resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,17 @@ def update
def set_link_options
account_type_name = accountable_type.name

# Get all available provider configs dynamically for this account type
@provider_configs = Provider::Factory.connection_configs_for_account_type(
account_type: account_type_name,
family: Current.family
)

eligible_keys = Provider::ConnectionRegistry.keys.select { |key|
Provider::ConnectionRegistry.adapter_for(key).supported_account_types.include?(account_type_name)
}
@provider_configs += eligible_keys
.flat_map { |key| Provider::ConnectionRegistry.adapter_for(key).connection_configs(family: Current.family) }
.uniq { |c| c[:key] }
end

def accountable_type
Expand Down
65 changes: 65 additions & 0 deletions app/controllers/concerns/provider_auth_flow_session.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Shared session-bridged flow state helpers for provider auth controllers.
#
# Auth flows that need to bridge cross-request state (OAuth2 redirect grants,
# Plaid Link OAuth-bank resumes, future protocols) stash the state in
# session[:provider_flows] keyed by a server-generated flow_id. The connection
# itself is created at flow completion when valid credentials exist.
#
# Each flow record is a Hash with at least { "created_at" => Time.current.to_i }.
# Records older than FLOW_TTL are pruned on write. The MAX_FLOWS cap drops the
# oldest entries first to prevent unbounded session growth.
module ProviderAuthFlowSession
extend ActiveSupport::Concern

FLOW_TTL = 1.hour
MAX_FLOWS = 20

private

def write_flow!(flow_id, state)
session[:provider_flows] ||= {}
pruned = session[:provider_flows].reject { |_, v| flow_expired?(v) }
pruned = pruned.merge(flow_id => state)

# Cap at MAX_FLOWS most-recent. Sort by created_at descending, keep the top N.
if pruned.size > MAX_FLOWS
pruned = pruned.sort_by { |_, v| -v["created_at"].to_i }.first(MAX_FLOWS).to_h
end
session[:provider_flows] = pruned
end

def peek_flow(flow_id)
return nil if flow_id.blank?
flow = session[:provider_flows]&.dig(flow_id)
return nil unless flow.is_a?(Hash)
return nil if flow_expired?(flow)
flow
end

def consume_flow(flow_id)
flow = peek_flow(flow_id)
return nil unless flow
session[:provider_flows] = (session[:provider_flows] || {}).except(flow_id)
flow
end

def flow_expired?(flow)
return true unless flow.is_a?(Hash)
flow["created_at"].to_i < FLOW_TTL.seconds.ago.to_i
end

def write_active_link_flow!(provider_key, flow_id)
session[:active_link_flows] ||= {}
session[:active_link_flows][provider_key.to_s] = flow_id
end

def peek_active_link_flow(provider_key)
session.dig(:active_link_flows, provider_key.to_s)
end

def consume_active_link_flow!(provider_key)
flow_id = peek_active_link_flow(provider_key)
session[:active_link_flows]&.delete(provider_key.to_s)
flow_id
end
end
114 changes: 114 additions & 0 deletions app/controllers/embedded_link_callbacks_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Generic controller for EmbeddedLink-style auth flows (Plaid Link, MX Connect
# Widget, Yodlee FastLink, Akoya Connect — vendor-hosted modal in-page that
# returns an opaque public_token to JS, exchanged once server-side for a
# long-lived access_token).
#
# Routes (parameterized by provider_key, all under /auth):
# GET /provider_connections/:provider_key/auth/new
# POST /provider_connections/:provider_key/auth/finish/:flow_id
#
# Provider-specific work is delegated to the adapter via the
# Provider::ConnectionAdapter EmbeddedLink contract:
# - .start_link_flow(family:, flow_id:, params:, resume_url:, oauth_redirect_url:, webhooks_url:)
# - .complete_link_flow(family:, flow:, params:)
# - .js_controller_name
# - .js_data_for(flow:, is_resume:, urls:)
#
# Cross-request flow state lives in session[:provider_flows] (see
# ProviderAuthFlowSession concern). No Provider::Connection is created until
# #create completes the exchange with valid credentials.
class EmbeddedLinkCallbacksController < ApplicationController
include ProviderAuthFlowSession

before_action :require_admin!
before_action :resolve_adapter!

def new
flow_id = SecureRandom.hex(16)
flow = @adapter.start_link_flow(
family: Current.family,
flow_id: flow_id,
params: params,
resume_url: new_provider_link_url(provider_key: provider_key),
oauth_redirect_url: provider_auth_url(provider_key: provider_key, host: configured_host),
webhooks_url: webhooks_provider_url(provider_key: provider_key)
)
write_flow!(flow_id, flow)
write_active_link_flow!(provider_key, flow_id)
render_link_view(flow_id: flow_id, flow: flow, is_resume: false)
rescue ArgumentError => e
Rails.logger.warn("[EmbeddedLinkCallbacksController] #{provider_key}#new rejected: #{e.message}")
head :bad_request
rescue StandardError => e
# Ask the adapter to translate the upstream error into a user-actionable
# alert. If it can (e.g. Plaid recognises "OAuth redirect URI must be
# configured" and tells the user the exact URL to paste), we redirect
# back to settings/providers with a structured flash entry — rendered
# there as a prominent inline block (not the tiny toast that flash[:alert]
# would otherwise become — those are line-clamped at 3 lines and 320px).
# If the adapter returns nil we re-raise so the dev-mode error page
# surfaces the underlying bug.
result = @adapter.humanize_link_error(e, redirect_uri: provider_auth_url(provider_key: provider_key, host: configured_host))
raise unless result
Rails.logger.warn("[EmbeddedLinkCallbacksController] #{provider_key}#new translated #{e.class}: #{e.message}")
flash[:provider_setup_error] = result.merge("provider_key" => provider_key)
redirect_to settings_providers_path
end

def create
flow = consume_flow(params[:flow_id])
consume_active_link_flow!(provider_key)
unless flow
Rails.logger.warn("[EmbeddedLinkCallbacksController] #{provider_key} flow expired/missing for family=#{Current.family&.id}")
redirect_to settings_providers_path, alert: t("provider.connections.connection_failed")
return
end

@connection = @adapter.complete_link_flow(family: Current.family, flow: flow, params: params)

begin
@connection.discover_accounts!
rescue => e
Rails.logger.warn("[EmbeddedLinkCallbacksController] discover_accounts! failed for connection=#{@connection.id}: #{e.class}: #{e.message}")
end

redirect_to setup_provider_connection_path(@connection),
notice: t("provider.connections.connected")
rescue Provider::Auth::TransientError,
Provider::Auth::ConsentExpiredError,
Provider::Auth::ReauthRequiredError,
Provider::Error,
ActiveRecord::RecordInvalid,
ActiveRecord::RecordNotFound => e
Rails.logger.warn("[EmbeddedLinkCallbacksController] #{provider_key}#create failed: #{e.class}: #{e.message}")
redirect_to settings_providers_path, alert: t("provider.connections.connection_failed")
end

private

def provider_key
params[:provider_key].to_s
end

def resolve_adapter!
@adapter = Provider::ConnectionRegistry.adapter_for(provider_key)
unless @adapter.auth_class == Provider::Auth::EmbeddedLink
head :not_found
end
rescue NotImplementedError
head :not_found
end

def render_link_view(flow_id:, flow:, is_resume:)
# Compute every URL the adapter's JS may need here — adapters MUST NOT
# touch Rails.application.routes themselves. Keeps routing knowledge
# in one layer.
urls = {
complete: finish_provider_link_path(provider_key: provider_key, flow_id: flow_id),
sync: (flow["connection_id"] ? sync_provider_connection_path(flow["connection_id"]) : nil),
post_sync_redirect: accounts_path
}
@js_data = @adapter.js_data_for(flow: flow, is_resume: is_resume, urls: urls)
render :new
end
end
2 changes: 1 addition & 1 deletion app/controllers/enable_banking_items_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,7 @@ def link_existing_account
enable_banking_account = EnableBankingAccount.find(params[:enable_banking_account_id])

# Guard: only manual accounts can be linked (no existing provider links or legacy IDs)
if @account.account_providers.any? || @account.plaid_account_id.present? || @account.simplefin_account_id.present?
if @account.account_providers.any? || @account.simplefin_account_id.present?
flash[:alert] = t("enable_banking_items.link_existing_account.errors.only_manual")
if turbo_frame_request?
return render turbo_stream: Array(flash_notification_stream_items)
Expand Down
12 changes: 12 additions & 0 deletions app/controllers/migration_notices_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class MigrationNoticesController < ApplicationController
before_action :require_admin!

# DELETE /migration_notices/:key — admin acknowledges an action-required
# banner, hiding it from this family until a future migration re-registers
# it under a new key.
def destroy
Current.family.dismiss_migration_notice!(params[:key])
redirect_back fallback_location: root_path,
notice: t("migration_notices.dismissed")
end
end
Loading
Loading