feat:Add SnapTrade OAuth device flow (#2523)

* Add SnapTrade OAuth connection flow

* Restore SnapTrade brokerage portal links

* Guard SnapTrade OAuth setup completion

* Move SnapTrade OAuth start to POST

* Use one SnapTrade item in provider panel

* Fix SnapTrade OAuth controller tests

* Fix SnapTrade OAuth drawer completion redirect

* Update SnapTrade limits message.

* Restrict SnapTrade OAuth scopes
This commit is contained in:
Juan José Mata
2026-06-29 00:30:03 +02:00
committed by GitHub
parent 174a2e19f9
commit b3f70c8951
13 changed files with 681 additions and 96 deletions

View File

@@ -1,6 +1,8 @@
class SnaptradeItemsController < ApplicationController
PERMITTED_OAUTH_SCOPES = %w[read].freeze
before_action :set_snaptrade_item, only: [ :show, :edit, :update, :destroy, :sync, :connect, :setup_accounts, :complete_account_setup, :connections, :start_oauth_device_flow, :complete_oauth_device_flow, :delete_connection, :delete_orphaned_user ]
before_action :require_admin!, only: [ :new, :create, :preload_accounts, :select_accounts, :link_accounts, :select_existing_account, :link_existing_account, :edit, :update, :destroy, :sync, :connect, :callback, :setup_accounts, :complete_account_setup, :connections, :start_oauth_device_flow, :complete_oauth_device_flow, :delete_connection, :delete_orphaned_user ]
before_action :require_admin!, only: [ :new, :create, :preload_accounts, :select_accounts, :link_accounts, :select_existing_account, :link_existing_account, :oauth_connect, :start_oauth_connect, :edit, :update, :destroy, :sync, :connect, :callback, :setup_accounts, :complete_account_setup, :connections, :start_oauth_device_flow, :complete_oauth_device_flow, :delete_connection, :delete_orphaned_user ]
def index
@snaptrade_items = Current.family.snaptrade_items.ordered
@@ -22,11 +24,13 @@ class SnaptradeItemsController < ApplicationController
if @snaptrade_item.save
# Register user with SnapTrade after saving credentials
begin
@snaptrade_item.ensure_user_registered!
rescue => e
Rails.logger.error "SnapTrade user registration failed: #{e.message}"
# Don't fail the whole operation - user can retry connection later
if @snaptrade_item.credentials_configured?
begin
@snaptrade_item.ensure_user_registered!
rescue => e
Rails.logger.error "SnapTrade user registration failed: #{e.message}"
# Don't fail the whole operation - user can retry connection later
end
end
if turbo_frame_request?
@@ -162,7 +166,7 @@ class SnaptradeItemsController < ApplicationController
latest_sync = @snaptrade_item.syncs.ordered.first
should_sync = latest_sync.nil? || !latest_sync.completed?
if no_accounts && !@snaptrade_item.syncing? && should_sync
if @snaptrade_item.user_registered? && no_accounts && !@snaptrade_item.syncing? && should_sync
@snaptrade_item.sync_later
end
@@ -257,8 +261,62 @@ class SnaptradeItemsController < ApplicationController
}
end
def oauth_connect
assign_oauth_connect_context
unless Provider::Snaptrade.oauth_client_id_configured?
@error_message = snaptrade_oauth_client_id_missing_message
render :oauth_device_flow, status: :unprocessable_entity, formats: :html
return
end
@snaptrade_item = if params[:item_id].present?
Current.family.snaptrade_items.find(params[:item_id])
else
current_snaptrade_item
end
render :oauth_device_flow
rescue ActiveRecord::Encryption::Errors::Decryption => e
Rails.logger.error "SnapTrade decryption error for item #{@snaptrade_item&.id}: #{e.class} - #{e.message}"
@error_message = t("snaptrade_items.connect.decryption_failed")
render :oauth_device_flow, status: :unprocessable_entity, formats: :html
rescue ActiveRecord::ActiveRecordError, ActiveRecord::Encryption::Errors::Base => e
Rails.logger.error "SnapTrade OAuth connect error: #{e.class} - #{e.message}"
@error_message = start_oauth_device_flow_error_message
render :oauth_device_flow, status: :unprocessable_entity, formats: :html
end
def start_oauth_connect
assign_oauth_connect_context
unless Provider::Snaptrade.oauth_client_id_configured?
@error_message = snaptrade_oauth_client_id_missing_message
render :oauth_device_flow, status: :unprocessable_entity, formats: :html
return
end
@snaptrade_item = if params[:item_id].present?
Current.family.snaptrade_items.find(params[:item_id])
else
current_snaptrade_item || Current.family.snaptrade_items.create!(name: t("snaptrade_items.default_name"))
end
@device_authorization = @snaptrade_item.start_oauth_device_flow(scope: @oauth_scope)
render :oauth_device_flow
rescue ActiveRecord::Encryption::Errors::Decryption => e
Rails.logger.error "SnapTrade decryption error for item #{@snaptrade_item&.id}: #{e.class} - #{e.message}"
@error_message = t("snaptrade_items.connect.decryption_failed")
render :oauth_device_flow, status: :unprocessable_entity, formats: :html
rescue Provider::Snaptrade::Error, ActiveRecord::ActiveRecordError, ActiveRecord::Encryption::Errors::Base => e
Rails.logger.error "SnapTrade OAuth connect error: #{e.class} - #{e.message}"
@error_message = oauth_connect_error_message(e)
render :oauth_device_flow, status: :unprocessable_entity, formats: :html
end
def start_oauth_device_flow
render json: @snaptrade_item.start_oauth_device_flow(scope: params[:scope].presence || "read")
render json: @snaptrade_item.start_oauth_device_flow(scope: permitted_oauth_scope)
rescue ActiveRecord::Encryption::Errors::Decryption => e
Rails.logger.error "SnapTrade decryption error for item #{@snaptrade_item.id}: #{e.class} - #{e.message}"
render json: { error: t("snaptrade_items.connect.decryption_failed") }, status: :unprocessable_entity
@@ -274,17 +332,42 @@ class SnaptradeItemsController < ApplicationController
end
token_response = @snaptrade_item.complete_oauth_device_flow!(device_code: params[:device_code])
render json: {
token_type: token_response["token_type"],
scope: token_response["scope"],
expires_in: token_response["expires_in"],
expires_at: @snaptrade_item.oauth_token_expires_at&.iso8601
}
if request.format.json?
render json: {
token_type: token_response["token_type"],
scope: token_response["scope"],
expires_in: token_response["expires_in"],
expires_at: @snaptrade_item.oauth_token_expires_at&.iso8601
}
else
if prepare_snaptrade_item_for_setup_after_oauth
redirect_after_oauth_completion setup_accounts_snaptrade_item_path(
@snaptrade_item,
accountable_type: params[:accountable_type].presence,
return_to: params[:return_to].presence
), notice: t(".success", default: "SnapTrade authorization complete.")
else
redirect_after_oauth_completion settings_providers_path, alert: snaptrade_oauth_setup_incomplete_message
end
end
rescue Provider::Snaptrade::ApiError => e
render json: oauth_error_payload(e), status: e.status_code || :unprocessable_entity
if request.format.json?
render json: oauth_error_payload(e), status: e.status_code || :unprocessable_entity
else
payload = oauth_error_payload(e)
@error_message = payload["error_description"].presence || payload["error"]
restore_device_authorization_from_params
render :oauth_device_flow, status: e.status_code || :unprocessable_entity, formats: :html
end
rescue Provider::Snaptrade::Error, ActiveRecord::ActiveRecordError, ActiveRecord::Encryption::Errors::Base => e
Rails.logger.error "SnapTrade OAuth device token error: #{e.class} - #{e.message}"
render json: { error: complete_oauth_device_flow_error_message }, status: :unprocessable_entity
if request.format.json?
render json: { error: complete_oauth_device_flow_error_message }, status: :unprocessable_entity
else
@error_message = complete_oauth_device_flow_error_message
restore_device_authorization_from_params
render :oauth_device_flow, status: :unprocessable_entity, formats: :html
end
end
# Delete a brokerage connection
@@ -387,7 +470,7 @@ class SnaptradeItemsController < ApplicationController
snaptrade_item.sync_later unless snaptrade_item.syncing?
redirect_to setup_accounts_snaptrade_item_path(snaptrade_item)
else
redirect_to connect_snaptrade_item_path(snaptrade_item)
redirect_to oauth_connect_snaptrade_items_path(item_id: snaptrade_item.id)
end
end
@@ -405,7 +488,7 @@ class SnaptradeItemsController < ApplicationController
redirect_to setup_accounts_snaptrade_item_path(snaptrade_item, accountable_type: @accountable_type, return_to: @return_to)
else
store_snaptrade_resume_context(return_to: @return_to, accountable_type: @accountable_type)
redirect_to connect_snaptrade_item_path(snaptrade_item)
redirect_to oauth_connect_snaptrade_items_path(item_id: snaptrade_item.id, accountable_type: @accountable_type, return_to: @return_to)
end
end
@@ -485,6 +568,53 @@ class SnaptradeItemsController < ApplicationController
[ resume[:return_to], resume[:accountable_type] ]
end
def restore_device_authorization_from_params
@device_authorization = params.permit(
:device_code,
:user_code,
:verification_uri,
:verification_uri_complete,
:expires_in,
:interval
).to_h
@return_to = params[:return_to]
@accountable_type = params[:accountable_type]
end
def assign_oauth_connect_context
@return_to = params[:return_to]
@accountable_type = params[:accountable_type]
@oauth_scope = permitted_oauth_scope
end
def permitted_oauth_scope
requested_scope = params[:scope].to_s
return requested_scope if PERMITTED_OAUTH_SCOPES.include?(requested_scope)
"read"
end
def prepare_snaptrade_item_for_setup_after_oauth
if !@snaptrade_item.user_registered? && @snaptrade_item.credentials_configured?
@snaptrade_item.ensure_user_registered!
end
return false unless @snaptrade_item.user_registered?
@snaptrade_item.sync_later unless @snaptrade_item.syncing?
true
end
def redirect_after_oauth_completion(path, notice: nil, alert: nil)
if turbo_frame_request?
flash[:notice] = notice if notice.present?
flash[:alert] = alert if alert.present?
render turbo_stream: turbo_stream.action(:redirect, path)
else
redirect_to path, notice: notice, alert: alert
end
end
def snaptrade_item_params
params.require(:snaptrade_item).permit(
:name,
@@ -551,6 +681,28 @@ class SnaptradeItemsController < ApplicationController
)
end
def oauth_connect_error_message(error)
if error.is_a?(Provider::Snaptrade::ConfigurationError) && error.message.include?("OAuth client ID")
snaptrade_oauth_client_id_missing_message
else
start_oauth_device_flow_error_message
end
end
def snaptrade_oauth_client_id_missing_message
t(
"snaptrade_items.oauth_device_flow.missing_client_id",
default: "SnapTrade OAuth client ID is not configured. Add SNAPTRADE_OAUTH_CLIENT_ID to .env.local, restart the app, then try again."
)
end
def snaptrade_oauth_setup_incomplete_message
t(
"snaptrade_items.complete_oauth_device_flow.setup_incomplete",
default: "SnapTrade authorization is complete, but API credentials are required before accounts can sync."
)
end
def complete_oauth_device_flow_error_message
t(
"snaptrade_items.complete_oauth_device_flow.failed",

View File

@@ -91,8 +91,9 @@ module SettingsHelper
return { status: :off } unless @kraken_items&.any?
sync_based_summary(key)
when "snaptrade"
configured_item = @snaptrade_items&.find(&:credentials_configured?)
configured_item = @snaptrade_items&.find { |item| item.credentials_configured? || item.oauth_configured? }
return { status: :off } unless configured_item
unless configured_item.user_registered?
return { status: :warn, meta: t("settings.providers.meta.registration_needed") }
end

View File

@@ -19,21 +19,26 @@ class Provider::Snaptrade
OAUTH_DISCOVERY_URL = "https://api.snaptrade.com/.well-known/oauth-authorization-server".freeze
DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code".freeze
attr_reader :client, :client_id, :consumer_key
def initialize(client_id:, consumer_key:)
raise ConfigurationError, "client_id is required" if client_id.blank?
raise ConfigurationError, "consumer_key is required" if consumer_key.blank?
attr_reader :client_id, :consumer_key
def initialize(client_id: nil, consumer_key: nil)
@client_id = client_id
@consumer_key = consumer_key
return if client_id.blank? && consumer_key.blank?
raise ConfigurationError, "client_id is required" if client_id.blank?
raise ConfigurationError, "consumer_key is required" if consumer_key.blank?
configuration = SnapTrade::Configuration.new
configuration.client_id = client_id
configuration.consumer_key = consumer_key
@client = SnapTrade::Client.new(configuration)
end
def self.oauth_client_id_configured?
Rails.configuration.x.snaptrade&.oauth_client_id.present?
end
def oauth_authorization_server_metadata
with_retries("oauth_authorization_server_metadata") do
response = oauth_connection.get(OAUTH_DISCOVERY_URL)
@@ -278,6 +283,10 @@ class Provider::Snaptrade
private
def client
@client || raise(ConfigurationError, "SnapTrade API credentials are required")
end
def handle_api_error(error, operation)
status = error.code
body = error.response_body

View File

@@ -25,8 +25,8 @@ class SnaptradeItem < ApplicationRecord
end
validates :name, presence: true
validates :client_id, presence: true, on: :create
validates :consumer_key, presence: true, on: :create
validates :client_id, presence: true, if: -> { consumer_key.present? }
validates :consumer_key, presence: true, if: -> { client_id.present? }
# Note: snaptrade_user_id and snaptrade_user_secret are populated after user registration
# via ensure_user_registered!, so we don't validate them on create
@@ -181,6 +181,10 @@ class SnaptradeItem < ApplicationRecord
client_id.present? && consumer_key.present?
end
def oauth_configured?
oauth_access_token.present?
end
# Override Syncable#syncing? to also show syncing state when activities are being
# fetched in the background. This ensures the UI shows the spinner until all data
# is truly imported, not just when the main sync job completes.

View File

@@ -14,6 +14,10 @@ module SnaptradeItem::Provided
)
end
def oauth_snaptrade_provider
snaptrade_provider || Provider::Snaptrade.new
end
# Clean up SnapTrade user when item is destroyed
def delete_snaptrade_user
return unless user_registered?
@@ -133,17 +137,11 @@ module SnaptradeItem::Provided
end
def start_oauth_device_flow(scope: "read")
provider = snaptrade_provider
raise Provider::Snaptrade::ConfigurationError, "SnapTrade provider not configured" unless provider
provider.start_device_authorization(scope: scope)
oauth_snaptrade_provider.start_device_authorization(scope: scope)
end
def complete_oauth_device_flow!(device_code:)
provider = snaptrade_provider
raise Provider::Snaptrade::ConfigurationError, "SnapTrade provider not configured" unless provider
token_response = provider.poll_device_token(device_code: device_code)
token_response = oauth_snaptrade_provider.poll_device_token(device_code: device_code)
update!(
oauth_access_token: token_response["access_token"],
oauth_refresh_token: token_response["refresh_token"],

View File

@@ -1,52 +1,116 @@
<div class="space-y-4">
<%= render DS::Alert.new(message: t("providers.snaptrade.free_tier_warning"), variant: :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? %>
<%= render DS::Alert.new(message: error_msg, variant: :error) %>
<% end %>
<%
snaptrade_item = Current.family.snaptrade_items.first_or_initialize(name: "SnapTrade Connection")
items = local_assigns[:snaptrade_items] || @snaptrade_items || Current.family.snaptrade_items.active.ordered
active_items = items.select { |item| !item.scheduled_for_deletion? }
snaptrade_item =
active_items.first ||
Current.family.snaptrade_items.build(name: "SnapTrade Connection")
is_new_record = snaptrade_item.new_record?
is_configured = snaptrade_item.persisted? && snaptrade_item.credentials_configured?
is_registered = snaptrade_item.persisted? && snaptrade_item.user_registered?
oauth_href =
if snaptrade_item.persisted?
oauth_connect_snaptrade_items_path(item_id: snaptrade_item.id)
else
oauth_connect_snaptrade_items_path
end
oauth_active = snaptrade_item.persisted? && snaptrade_item.oauth_token_active?
oauth_button_text =
if oauth_active
t("providers.snaptrade.oauth_reauthorize_button", default: "Reauthorize")
else
t("providers.snaptrade.oauth_connect_button", default: "Authorize")
end
%>
<%= styled_form_with model: snaptrade_item,
url: is_new_record ? snaptrade_items_path : snaptrade_item_path(snaptrade_item),
scope: :snaptrade_item,
method: is_new_record ? :post : :patch,
data: { turbo: true },
class: "space-y-3" do |form| %>
<%= form.text_field :client_id,
label: t("providers.snaptrade.client_id_label"),
placeholder: is_new_record ? t("providers.snaptrade.client_id_placeholder") : t("providers.snaptrade.client_id_update_placeholder"),
type: :password %>
<div class="bg-surface-inset rounded-xl p-4 space-y-3">
<div class="flex items-start gap-3">
<span class="w-9 h-9 rounded-lg bg-success/10 flex items-center justify-center shrink-0">
<%= icon "key-round", class: "w-4 h-4 text-success" %>
</span>
<div class="min-w-0">
<p class="text-sm font-medium text-primary"><%= t("providers.snaptrade.oauth_title", default: "SnapTrade OAuth") %></p>
<p class="text-sm text-secondary">
<% if oauth_active %>
<%= t("providers.snaptrade.oauth_status_authorized", default: "Authorized for SnapTrade.") %>
<% else %>
<%= t("providers.snaptrade.oauth_status_ready", default: "Use a device code to authorize Sure for SnapTrade.") %>
<% end %>
</p>
</div>
</div>
<%= form.text_field :consumer_key,
label: t("providers.snaptrade.consumer_key_label"),
placeholder: is_new_record ? t("providers.snaptrade.consumer_key_placeholder") : t("providers.snaptrade.consumer_key_update_placeholder"),
type: :password %>
<%= render DS::Link.new(
text: oauth_button_text,
icon: "external-link",
variant: :primary,
full_width: true,
href: oauth_href,
data: { turbo_frame: "drawer" }
) %>
</div>
<div class="flex justify-end">
<%= form.submit is_new_record ? t("providers.snaptrade.save_button") : t("providers.snaptrade.update_button") %>
<%= render DS::Disclosure.new(variant: :card_inset) do |disclosure| %>
<% disclosure.with_summary_content do %>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-primary">
<%= t("providers.snaptrade.legacy_credentials_title", default: "Use legacy API credentials") %>
</p>
<p class="text-xs text-secondary mt-1">
<%= t("providers.snaptrade.legacy_credentials_description", default: "Use Client ID and Consumer Key setup if your SnapTrade account has not enabled OAuth.") %>
</p>
</div>
<%= icon(
"chevron-down",
class: "mt-0.5 text-secondary group-open:rotate-180 motion-safe:transition-transform motion-safe:duration-150"
) %>
</div>
<% end %>
<div class="mt-4 space-y-3">
<%= 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")
] %>
<%= styled_form_with model: snaptrade_item,
url: is_new_record ? snaptrade_items_path : snaptrade_item_path(snaptrade_item),
scope: :snaptrade_item,
method: is_new_record ? :post : :patch,
data: { turbo: true },
class: "space-y-3" do |form| %>
<%= form.text_field :client_id,
label: t("providers.snaptrade.client_id_label"),
placeholder: is_new_record ?
t("providers.snaptrade.client_id_placeholder") :
t("providers.snaptrade.client_id_update_placeholder"),
type: :password %>
<%= form.text_field :consumer_key,
label: t("providers.snaptrade.consumer_key_label"),
placeholder: is_new_record ?
t("providers.snaptrade.consumer_key_placeholder") :
t("providers.snaptrade.consumer_key_update_placeholder"),
type: :password %>
<div class="flex justify-end">
<%= form.submit is_new_record ? t("providers.snaptrade.save_button") : t("providers.snaptrade.update_button") %>
</div>
<% end %>
</div>
<% end %>
<% items = local_assigns[:snaptrade_items] || @snaptrade_items || Current.family.snaptrade_items.where.not(client_id: [nil, ""]) %>
<% if items&.any? %>
<% item = items.first %>
<% unless item.user_registered? %>
<% if snaptrade_item.persisted? %>
<% unless snaptrade_item.user_registered? %>
<div class="flex items-center gap-2 border-t border-primary pt-4 mt-4">
<span class="w-2 h-2 bg-warning rounded-full"></span>
<p class="text-sm text-secondary"><%= t("providers.snaptrade.status_needs_registration") %></p>
@@ -54,15 +118,14 @@
<% end %>
<% end %>
<% if items&.any? && items.first.user_registered? %>
<% item = items.first %>
<% if snaptrade_item.persisted? && snaptrade_item.user_registered? %>
<div class="border-t border-primary pt-4 mt-4">
<%= render DS::Disclosure.new(
variant: :inline,
data: {
controller: "lazy-load",
action: "toggle->lazy-load#toggled",
lazy_load_url_value: connections_snaptrade_item_path(item),
lazy_load_url_value: connections_snaptrade_item_path(snaptrade_item),
lazy_load_auto_open_param_value: "manage"
}
) do |disclosure| %>
@@ -70,9 +133,9 @@
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<p class="text-sm text-secondary">
<%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %>
<% if item.unlinked_accounts_count > 0 %>
<span class="text-warning">(<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>)</span>
<%= t("providers.snaptrade.status_connected", count: snaptrade_item.snaptrade_accounts.count) %>
<% if snaptrade_item.unlinked_accounts_count > 0 %>
<span class="text-warning">(<%= t("providers.snaptrade.needs_setup", count: snaptrade_item.unlinked_accounts_count) %>)</span>
<% end %>
</p>
</div>

View File

@@ -2,6 +2,9 @@
<%= tag.div id: dom_id(snaptrade_item) do %>
<% unlinked_count = snaptrade_item.unlinked_accounts_count %>
<% snaptrade_registered = snaptrade_item.user_registered? %>
<% brokerage_connect_href = snaptrade_registered ? connect_snaptrade_item_path(snaptrade_item) : oauth_connect_snaptrade_items_path(item_id: snaptrade_item.id) %>
<% brokerage_connect_frame = snaptrade_registered ? "_top" : :drawer %>
<%= render DS::Disclosure.new(variant: :card, open: true) do |disclosure| %>
<% disclosure.with_summary_content do %>
@@ -60,12 +63,13 @@
<% if Current.user&.admin? %>
<div class="flex items-center gap-2">
<% if snaptrade_item.requires_update? || !snaptrade_item.user_registered? %>
<% if snaptrade_item.requires_update? || !snaptrade_registered %>
<%= render DS::Link.new(
text: t(".reconnect"),
icon: "link",
variant: "secondary",
href: connect_snaptrade_item_path(snaptrade_item)
href: brokerage_connect_href,
frame: brokerage_connect_frame
) %>
<% else %>
<%= icon(
@@ -81,7 +85,8 @@
variant: "link",
text: t(".connect_brokerage"),
icon: "plus",
href: connect_snaptrade_item_path(snaptrade_item)
href: brokerage_connect_href,
frame: brokerage_connect_frame
) %>
<% if unlinked_count > 0 %>
<% menu.with_item(
@@ -150,7 +155,8 @@
text: t(".connect_brokerage"),
icon: "link",
variant: "primary",
href: connect_snaptrade_item_path(snaptrade_item)
href: brokerage_connect_href,
frame: brokerage_connect_frame
) %>
</div>
<% end %>

View File

@@ -0,0 +1,112 @@
<%= render DS::Dialog.new(frame: "drawer", responsive: true, auto_open: true) do |dialog| %>
<% dialog.with_header(custom_header: true) do %>
<%= render "settings/providers/drawer_header",
provider_key: "snaptrade",
title: t("snaptrade_items.oauth_device_flow.title", default: "Connect SnapTrade") %>
<% end %>
<% dialog.with_body do %>
<div class="space-y-4">
<p class="text-sm text-secondary">
<%= t("snaptrade_items.oauth_device_flow.subtitle", default: "Authorize Sure from SnapTrade") %>
</p>
<% if @error_message.present? %>
<%= render DS::Alert.new(message: @error_message, variant: :error) %>
<% end %>
<% if @device_authorization.present? %>
<div class="bg-surface-inset rounded-xl p-4 space-y-4">
<p class="text-sm text-secondary">
<%= t(
"snaptrade_items.oauth_device_flow.instructions",
default: "Open SnapTrade and confirm this device code, then return here to complete authorization."
) %>
</p>
<div class="space-y-1">
<p class="text-xs uppercase text-secondary">
<%= t("snaptrade_items.oauth_device_flow.code_label", default: "Device code") %>
</p>
<p class="text-2xl font-semibold text-primary font-mono"><%= @device_authorization["user_code"] %></p>
</div>
<%= render DS::Link.new(
text: t("snaptrade_items.oauth_device_flow.open_snaptrade", default: "Open SnapTrade"),
href: @device_authorization["verification_uri_complete"].presence || @device_authorization["verification_uri"],
variant: :primary,
icon: "external-link",
target: "_blank",
rel: "noopener noreferrer",
full_width: true
) %>
</div>
<%= form_with url: complete_oauth_device_flow_snaptrade_item_path(@snaptrade_item),
method: :post,
data: { turbo_frame: "drawer" },
class: "space-y-3" do %>
<%= hidden_field_tag :device_code, @device_authorization["device_code"] %>
<%= hidden_field_tag :user_code, @device_authorization["user_code"] %>
<%= hidden_field_tag :verification_uri, @device_authorization["verification_uri"] %>
<%= hidden_field_tag :verification_uri_complete, @device_authorization["verification_uri_complete"] %>
<%= hidden_field_tag :expires_in, @device_authorization["expires_in"] %>
<%= hidden_field_tag :interval, @device_authorization["interval"] %>
<%= hidden_field_tag :return_to, @return_to if @return_to.present? %>
<%= hidden_field_tag :accountable_type, @accountable_type if @accountable_type.present? %>
<div class="flex justify-end gap-2">
<%= render DS::Link.new(
text: t("snaptrade_items.oauth_device_flow.cancel_button", default: "Cancel"),
variant: :secondary,
href: connect_form_settings_providers_path(provider_key: "snaptrade"),
data: { turbo_frame: "drawer" }
) %>
<%= render DS::Button.new(
text: t("snaptrade_items.oauth_device_flow.complete_button", default: "I've authorized SnapTrade"),
variant: :primary,
icon: "check",
type: :submit
) %>
</div>
<% end %>
<% else %>
<% if @error_message.blank? %>
<%= form_with url: start_oauth_connect_snaptrade_items_path,
method: :post,
data: { turbo_frame: "drawer" },
class: "space-y-3" do %>
<%= hidden_field_tag :item_id, @snaptrade_item.id if @snaptrade_item&.persisted? %>
<%= hidden_field_tag :scope, @oauth_scope if @oauth_scope.present? %>
<%= hidden_field_tag :return_to, @return_to if @return_to.present? %>
<%= hidden_field_tag :accountable_type, @accountable_type if @accountable_type.present? %>
<div class="flex justify-end gap-2">
<%= render DS::Link.new(
text: t("snaptrade_items.oauth_device_flow.cancel_button", default: "Cancel"),
variant: :secondary,
href: connect_form_settings_providers_path(provider_key: "snaptrade"),
data: { turbo_frame: "drawer" }
) %>
<%= render DS::Button.new(
text: t("snaptrade_items.oauth_device_flow.start_button", default: "Start authorization"),
variant: :primary,
icon: "external-link",
type: :submit
) %>
</div>
<% end %>
<% else %>
<div class="flex justify-end">
<%= render DS::Link.new(
text: t("snaptrade_items.oauth_device_flow.cancel_button", default: "Cancel"),
variant: :secondary,
href: connect_form_settings_providers_path(provider_key: "snaptrade"),
data: { turbo_frame: "drawer" }
) %>
</div>
<% end %>
<% end %>
</div>
<% end %>
<% end %>

View File

@@ -26,7 +26,7 @@
</ul>
<p class="text-xs text-warning mt-2">
<%= icon "alert-triangle", size: "xs", class: "inline-block mr-1" %>
<%= t("snaptrade_items.setup_accounts.free_tier_note", default: "SnapTrade free tier allows 5 brokerage connections. Check your SnapTrade dashboard for current usage.") %>
<%= t("snaptrade_items.setup_accounts.free_tier_note", default: "SnapTrade free tier allows 20 brokerage connections.") %>
</p>
</div>
</div>
@@ -70,11 +70,23 @@
</p>
</div>
<div class="flex gap-3 justify-center">
<%
brokerage_connect_href = if @snaptrade_item.user_registered?
connect_snaptrade_item_path(@snaptrade_item)
else
oauth_connect_snaptrade_items_path(
item_id: @snaptrade_item.id,
accountable_type: params[:accountable_type],
return_to: params[:return_to]
)
end
brokerage_connect_frame = @snaptrade_item.user_registered? ? "_top" : "drawer"
%>
<%= render DS::Link.new(
text: t("snaptrade_items.setup_accounts.try_again", default: "Connect Brokerage"),
variant: "primary",
href: connect_snaptrade_item_path(@snaptrade_item),
frame: "_top"
href: brokerage_connect_href,
frame: brokerage_connect_frame
) %>
<%= render DS::Link.new(
text: t("snaptrade_items.setup_accounts.back_to_settings", default: "Back to Settings"),

View File

@@ -28,6 +28,22 @@ en:
not_configured: "SnapTrade is not configured."
select_accounts:
not_configured: "SnapTrade is not configured."
oauth_device_flow:
title: "Connect SnapTrade"
subtitle: "Authorize Sure for SnapTrade"
instructions: "Open SnapTrade and confirm this device code, then return here to complete authorization."
code_label: "Device code"
open_snaptrade: "Open SnapTrade"
start_button: "Start authorization"
complete_button: "I've authorized SnapTrade"
cancel_button: "Cancel"
missing_client_id: "SnapTrade OAuth client ID is not configured. Add SNAPTRADE_OAUTH_CLIENT_ID to .env.local, restart the app, then try again."
complete_oauth_device_flow:
success: "SnapTrade authorization complete."
setup_incomplete: "SnapTrade authorization is complete, but API credentials are required before accounts can sync."
failed: "Unable to complete SnapTrade OAuth device authorization. Please try again."
start_oauth_device_flow:
failed: "Unable to start SnapTrade OAuth device authorization. Please try again."
select_existing_account:
not_found: "Account or SnapTrade configuration not found."
title: "Link to SnapTrade Account"
@@ -67,7 +83,7 @@ en:
info_cost_basis: "Cost basis per position (when available)"
info_activities: "Trade history with activity labels (Buy, Sell, Dividend, etc.)"
info_history: "Up to 3 years of transaction history"
free_tier_note: "SnapTrade free tier allows 5 brokerage connections. Check your SnapTrade dashboard for current usage."
free_tier_note: "SnapTrade free tier allows 20 brokerage connections."
no_accounts_title: "No Accounts Found"
no_accounts_message: "No brokerage accounts were found. This can happen if you cancelled the connection or if your brokerage isn't supported."
try_again: "Connect Brokerage"
@@ -121,7 +137,14 @@ 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: "SnapTrade's free tier covers 5 brokerage connections. Upgrade on SnapTrade for more."
free_tier_warning: "SnapTrade's free tier covers 20 brokerage connections."
oauth_title: "SnapTrade OAuth"
oauth_status_ready: "Use a device code to authorize Sure for SnapTrade."
oauth_status_authorized: "Authorized with SnapTrade."
oauth_connect_button: "Connect with SnapTrade"
oauth_reauthorize_button: "Reauthorize"
legacy_credentials_title: "Use legacy API credentials"
legacy_credentials_description: "Use Client ID and Consumer Key setup if your SnapTrade account has not enabled OAuth."
client_id_label: "Client ID"
client_id_placeholder: "Enter your SnapTrade Client ID"
client_id_update_placeholder: "Enter new Client ID to update"

View File

@@ -105,6 +105,8 @@ Rails.application.routes.draw do
get :select_existing_account
post :link_existing_account
get :callback
get :oauth_connect
post :start_oauth_connect
end
member do

View File

@@ -60,7 +60,7 @@ class SnaptradeItemsControllerTest < ActionDispatch::IntegrationTest
.stubs(:complete_oauth_device_flow!)
.raises(error)
post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item), params: { device_code: "device-code" }
post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item), params: { device_code: "device-code" }, as: :json
assert_response :bad_request
payload = JSON.parse(response.body)
@@ -80,7 +80,7 @@ class SnaptradeItemsControllerTest < ActionDispatch::IntegrationTest
.stubs(:complete_oauth_device_flow!)
.raises(error)
post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item), params: { device_code: "device-code" }
post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item), params: { device_code: "device-code" }, as: :json
assert_response :bad_gateway
payload = JSON.parse(response.body)
@@ -92,7 +92,7 @@ class SnaptradeItemsControllerTest < ActionDispatch::IntegrationTest
.stubs(:complete_oauth_device_flow!)
.raises(Provider::Snaptrade::ConfigurationError.new("missing secret at /srv/app/config.yml"))
post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item), params: { device_code: "device-code" }
post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item), params: { device_code: "device-code" }, as: :json
assert_response :unprocessable_entity
payload = JSON.parse(response.body)
@@ -111,6 +111,116 @@ class SnaptradeItemsControllerTest < ActionDispatch::IntegrationTest
assert_equal "Unable to start SnapTrade OAuth device authorization. Please try again.", payload["error"]
end
test "start oauth device flow falls back to read for invalid scope" do
SnaptradeItem.any_instance
.expects(:start_oauth_device_flow)
.with(scope: "read")
.returns("device_code" => "device-code")
post start_oauth_device_flow_snaptrade_item_url(@snaptrade_item), params: { scope: "write" }
assert_response :success
assert_equal "device-code", JSON.parse(response.body)["device_code"]
end
test "oauth_connect renders start form without side effects" do
sign_out
sign_in @user = users(:empty)
@user.family.snaptrade_items.destroy_all
Provider::Snaptrade.stubs(:oauth_client_id_configured?).returns(true)
SnaptradeItem.any_instance
.expects(:start_oauth_device_flow)
.never
assert_no_difference "SnaptradeItem.count" do
get oauth_connect_snaptrade_items_url
end
assert_response :success
assert_match "turbo-frame id=\"drawer\"", response.body
assert_match "Start authorization", response.body
assert_no_match "Open SnapTrade", response.body
end
test "start_oauth_connect renders device authorization instructions" do
Provider::Snaptrade.stubs(:oauth_client_id_configured?).returns(true)
SnaptradeItem.any_instance
.stubs(:start_oauth_device_flow)
.returns(
"device_code" => "device-code",
"user_code" => "ABCD-EFGH",
"verification_uri" => "https://dashboard.snaptrade.com/activate",
"verification_uri_complete" => "https://dashboard.snaptrade.com/activate?user_code=ABCD-EFGH",
"expires_in" => 600,
"interval" => 5
)
post start_oauth_connect_snaptrade_items_url
assert_response :success
assert_match "turbo-frame id=\"drawer\"", response.body
assert_match "ABCD-EFGH", response.body
assert_match "Open SnapTrade", response.body
end
test "start_oauth_connect falls back to read for invalid scope" do
Provider::Snaptrade.stubs(:oauth_client_id_configured?).returns(true)
SnaptradeItem.any_instance
.expects(:start_oauth_device_flow)
.with(scope: "read")
.returns(
"device_code" => "device-code",
"user_code" => "ABCD-EFGH",
"verification_uri" => "https://dashboard.snaptrade.com/activate",
"expires_in" => 600,
"interval" => 5
)
post start_oauth_connect_snaptrade_items_url, params: { scope: "write" }
assert_response :success
assert_match "ABCD-EFGH", response.body
end
test "oauth_connect explains missing oauth client id without creating item" do
sign_out
sign_in @user = users(:empty)
@user.family.snaptrade_items.destroy_all
Provider::Snaptrade.stubs(:oauth_client_id_configured?).returns(false)
assert_no_difference "SnaptradeItem.count" do
get oauth_connect_snaptrade_items_url
end
assert_response :unprocessable_entity
assert_match "SNAPTRADE_OAUTH_CLIENT_ID", response.body
end
test "start_oauth_connect creates oauth-only item when snaptrade is not configured" do
sign_out
sign_in @user = users(:empty)
@user.family.snaptrade_items.destroy_all
Provider::Snaptrade.stubs(:oauth_client_id_configured?).returns(true)
SnaptradeItem.any_instance
.stubs(:start_oauth_device_flow)
.returns(
"device_code" => "device-code",
"user_code" => "ABCD-EFGH",
"verification_uri" => "https://dashboard.snaptrade.com/activate",
"expires_in" => 600,
"interval" => 5
)
assert_difference "SnaptradeItem.count", 1 do
post start_oauth_connect_snaptrade_items_url
end
assert_response :success
assert_match "ABCD-EFGH", response.body
assert_not @user.family.snaptrade_items.reload.last.credentials_configured?
end
test "select_accounts redirects unregistered users into connect flow" do
sign_out
sign_in @user = users(:empty)
@@ -118,22 +228,110 @@ class SnaptradeItemsControllerTest < ActionDispatch::IntegrationTest
get select_accounts_snaptrade_items_url, params: { accountable_type: "Investment", return_to: "setup_accounts" }
assert_redirected_to connect_snaptrade_item_path(snaptrade_item)
assert_redirected_to oauth_connect_snaptrade_items_path(
item_id: snaptrade_item.id,
accountable_type: "Investment",
return_to: "setup_accounts"
)
end
test "callback resumes setup flow after first-time connect detour" do
test "complete oauth device flow registers credentialed items and routes to setup" do
sign_out
sign_in @user = users(:empty)
snaptrade_item = snaptrade_items(:pending_registration_item)
assert_difference "Sync.count", 1 do
get select_accounts_snaptrade_items_url, params: { accountable_type: "Investment", return_to: "setup_accounts" }
assert_redirected_to connect_snaptrade_item_path(snaptrade_item)
SnaptradeItem.any_instance
.stubs(:complete_oauth_device_flow!)
.returns(
"token_type" => "Bearer",
"scope" => "read",
"expires_in" => 3600
)
SnaptradeItem.any_instance
.stubs(:user_registered?)
.returns(false, true)
SnaptradeItem.any_instance
.expects(:ensure_user_registered!)
.once
.returns(true)
get callback_snaptrade_items_url, params: { item_id: snaptrade_item.id }
assert_difference "Sync.count", 1 do
post complete_oauth_device_flow_snaptrade_item_url(snaptrade_item), params: {
device_code: "device-code",
accountable_type: "Investment",
return_to: "setup_accounts"
}
end
assert_redirected_to setup_accounts_snaptrade_item_path(snaptrade_item, accountable_type: "Investment")
assert_redirected_to setup_accounts_snaptrade_item_path(
snaptrade_item,
accountable_type: "Investment",
return_to: "setup_accounts"
)
assert_equal "SnapTrade authorization complete.", flash[:notice]
end
test "complete oauth device flow streams top-level navigation from drawer frame" do
sign_out
sign_in @user = users(:empty)
snaptrade_item = snaptrade_items(:pending_registration_item)
SnaptradeItem.any_instance
.stubs(:complete_oauth_device_flow!)
.returns(
"token_type" => "Bearer",
"scope" => "read",
"expires_in" => 3600
)
SnaptradeItem.any_instance
.stubs(:user_registered?)
.returns(false, true)
SnaptradeItem.any_instance
.expects(:ensure_user_registered!)
.once
.returns(true)
setup_path = setup_accounts_snaptrade_item_path(
snaptrade_item,
accountable_type: "Investment",
return_to: "setup_accounts"
)
assert_difference "Sync.count", 1 do
post complete_oauth_device_flow_snaptrade_item_url(snaptrade_item), params: {
device_code: "device-code",
accountable_type: "Investment",
return_to: "setup_accounts"
}, headers: { "Turbo-Frame" => "drawer" }
end
assert_response :success
assert_equal "text/vnd.turbo-stream.html", response.media_type
assert_match %(<turbo-stream action="redirect"), response.body
assert_match ERB::Util.html_escape(setup_path), response.body
assert_equal "SnapTrade authorization complete.", flash[:notice]
end
test "complete oauth device flow does not mark oauth-only items as setup complete" do
sign_out
sign_in @user = users(:empty)
snaptrade_item = @user.family.snaptrade_items.create!(name: "OAuth-only SnapTrade")
SnaptradeItem.any_instance
.stubs(:complete_oauth_device_flow!)
.returns(
"token_type" => "Bearer",
"scope" => "read",
"expires_in" => 3600
)
assert_no_difference "Sync.count" do
post complete_oauth_device_flow_snaptrade_item_url(snaptrade_item), params: {
device_code: "device-code"
}
end
assert_redirected_to settings_providers_path
assert_match(/API credentials are required/, flash[:alert])
end
test "select_accounts redirects registered users to setup flow" do
@@ -145,13 +343,11 @@ class SnaptradeItemsControllerTest < ActionDispatch::IntegrationTest
test "preload_accounts redirects unregistered users into connect flow" do
sign_out
sign_in @user = users(:empty)
snaptrade_item = snaptrade_items(:pending_registration_item)
assert_no_difference "Sync.count" do
get preload_accounts_snaptrade_items_url
end
assert_redirected_to connect_snaptrade_item_path(snaptrade_item)
assert_redirected_to oauth_connect_snaptrade_items_path(item_id: snaptrade_items(:pending_registration_item).id)
end
test "preload_accounts redirects registered users to setup flow and queues sync" do
@@ -314,6 +510,8 @@ class SnaptradeItemsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
assert_select ".no-accounts-found", count: 1, message: "Expected the no-accounts UI to be shown after a completed sync with zero accounts"
assert_select "#snaptrade-sync-spinner", count: 0, message: "Expected the spinner to be hidden when there is no active sync"
assert_select "a[href=?]", connect_snaptrade_item_path(@snaptrade_item), text: /Connect Brokerage/
assert_no_match oauth_connect_snaptrade_items_path(item_id: @snaptrade_item.id), response.body
end
test "setup_accounts does not re-queue a sync when a sync is already in progress" do

View File

@@ -11,18 +11,23 @@ class SnaptradeItemTest < ActiveSupport::TestCase
assert_includes item.errors[:name], "can't be blank"
end
test "validates presence of client_id on create" do
test "requires client_id when consumer_key is present" do
item = SnaptradeItem.new(family: @family, name: "Test", consumer_key: "test")
assert_not item.valid?
assert_includes item.errors[:client_id], "can't be blank"
end
test "validates presence of consumer_key on create" do
test "requires consumer_key when client_id is present" do
item = SnaptradeItem.new(family: @family, name: "Test", client_id: "test")
assert_not item.valid?
assert_includes item.errors[:consumer_key], "can't be blank"
end
test "allows oauth-only items without api credentials" do
item = SnaptradeItem.new(family: @family, name: "Test")
assert item.valid?
end
test "credentials_configured? returns true when credentials are set" do
item = SnaptradeItem.new(
family: @family,