From 96713ee8b472eec6dfd136fcb43e88e6401933c2 Mon Sep 17 00:00:00 2001 From: soky srm Date: Wed, 29 Oct 2025 13:11:04 +0100 Subject: [PATCH] Add support for dynamic config UI (#256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add support for dynamic config UI * Add support for section description * Better dynamic class settings Added dynamic_fields hash field - Stores all undeclared settings [] method - Checks declared fields first, then falls back to dynamic hash []= method - Updates declared fields normally, stores others in hash No runtime field declaration - Fields are never dynamically created on the class * FIX proper lookup for provider keys - Also validate configurable values properly. - Change Provider factory to use Rails autoloading (Zeitwerk) * Fix factory The derive_adapter_name method relies on string manipulation ("PlaidAccount".sub(/Account$/, "") + "Adapter" → "PlaidAdapter"), but we already have explicit registration in place. * Make updates atomic, field-aware, and handle blanks explicitly * Small UX detail * Add support for PlaidEU in UI also - This looks like partial support atm --- .../settings/providers_controller.rb | 108 +++++++ app/models/provider/configurable.rb | 286 ++++++++++++++++++ app/models/provider/factory.rb | 71 +++-- app/models/provider/plaid_adapter.rb | 60 +++- app/models/provider/plaid_eu_adapter.rb | 61 ++++ app/models/provider/simplefin_adapter.rb | 17 ++ app/models/setting.rb | 56 ++++ app/views/settings/_settings_nav.html.erb | 1 + .../providers/_provider_form.html.erb | 83 +++++ app/views/settings/providers/show.html.erb | 15 + config/initializers/plaid.rb | 25 +- config/routes.rb | 1 + test/models/provider/registry_test.rb | 22 +- 13 files changed, 763 insertions(+), 43 deletions(-) create mode 100644 app/controllers/settings/providers_controller.rb create mode 100644 app/models/provider/configurable.rb create mode 100644 app/models/provider/plaid_eu_adapter.rb create mode 100644 app/views/settings/providers/_provider_form.html.erb create mode 100644 app/views/settings/providers/show.html.erb diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb new file mode 100644 index 000000000..c843d445c --- /dev/null +++ b/app/controllers/settings/providers_controller.rb @@ -0,0 +1,108 @@ +class Settings::ProvidersController < ApplicationController + layout "settings" + + guard_feature unless: -> { self_hosted? } + + before_action :ensure_admin, only: [ :show, :update ] + + def show + @breadcrumbs = [ + [ "Home", root_path ], + [ "Bank Sync Providers", nil ] + ] + + # Load all provider configurations + Provider::Factory.ensure_adapters_loaded + @provider_configurations = Provider::ConfigurationRegistry.all + end + + def update + # Build index of valid configurable fields with their metadata + Provider::Factory.ensure_adapters_loaded + valid_fields = {} + Provider::ConfigurationRegistry.all.each do |config| + config.fields.each do |field| + valid_fields[field.setting_key.to_s] = field + end + end + + updated_fields = [] + + # Perform all updates within a transaction for consistency + Setting.transaction do + provider_params.each do |param_key, param_value| + # Only process keys that exist in the configuration registry + field = valid_fields[param_key.to_s] + next unless field + + # Clean the value and convert blank/empty strings to nil + value = param_value.to_s.strip + value = nil if value.empty? + + # For secret fields only, skip placeholder values to prevent accidental overwrite + if field.secret && value == "********" + next + end + + # Set the value using dynamic hash-style access + Setting[field.setting_key] = value + updated_fields << param_key + end + end + + if updated_fields.any? + # Reload provider configurations if needed + reload_provider_configs(updated_fields) + + redirect_to settings_providers_path, notice: "Provider settings updated successfully" + else + redirect_to settings_providers_path, notice: "No changes were made" + end + rescue => error + Rails.logger.error("Failed to update provider settings: #{error.message}") + flash.now[:alert] = "Failed to update provider settings: #{error.message}" + render :show, status: :unprocessable_entity + end + + private + def provider_params + # Dynamically permit all provider configuration fields + Provider::Factory.ensure_adapters_loaded + permitted_fields = [] + + Provider::ConfigurationRegistry.all.each do |config| + config.fields.each do |field| + permitted_fields << field.setting_key + end + end + + params.require(:setting).permit(*permitted_fields) + end + + def ensure_admin + redirect_to settings_providers_path, alert: "Not authorized" unless Current.user.admin? + end + + # Reload provider configurations after settings update + def reload_provider_configs(updated_fields) + # Build a set of provider keys that had fields updated + updated_provider_keys = Set.new + + # Look up the provider key directly from the configuration registry + updated_fields.each do |field_key| + Provider::ConfigurationRegistry.all.each do |config| + field = config.fields.find { |f| f.setting_key.to_s == field_key.to_s } + if field + updated_provider_keys.add(field.provider_key) + break + end + end + end + + # Reload configuration for each updated provider + updated_provider_keys.each do |provider_key| + adapter_class = Provider::ConfigurationRegistry.get_adapter_class(provider_key) + adapter_class&.reload_configuration + end + end +end diff --git a/app/models/provider/configurable.rb b/app/models/provider/configurable.rb new file mode 100644 index 000000000..680d2cc2e --- /dev/null +++ b/app/models/provider/configurable.rb @@ -0,0 +1,286 @@ +# Module for providers to declare their configuration requirements +# +# Providers can declare their own configuration fields without needing to modify +# the Setting model. Settings are stored dynamically using RailsSettings::Base's +# hash-style access (Setting[:key] = value). +# +# Configuration fields are automatically registered and displayed in the UI at +# /settings/providers. The system checks Setting storage first, then ENV variables, +# then falls back to defaults. +# +# Example usage in an adapter: +# class Provider::PlaidAdapter < Provider::Base +# include Provider::Configurable +# +# configure do +# description <<~DESC +# Setup instructions: +# 1. Visit [Plaid Dashboard](https://dashboard.plaid.com) to get your API credentials +# 2. Configure your Client ID and Secret Key below +# DESC +# +# field :client_id, +# label: "Client ID", +# required: true, +# env_key: "PLAID_CLIENT_ID", +# description: "Your Plaid Client ID from the dashboard" +# +# field :secret, +# label: "Secret Key", +# required: true, +# secret: true, +# env_key: "PLAID_SECRET", +# description: "Your Plaid Secret key" +# +# field :environment, +# label: "Environment", +# required: false, +# env_key: "PLAID_ENV", +# default: "sandbox", +# description: "Plaid environment: sandbox, development, or production" +# end +# end +# +# The provider_key is automatically derived from the class name: +# Provider::PlaidAdapter -> "plaid" +# Provider::SimplefinAdapter -> "simplefin" +# +# Fields are stored with keys like "plaid_client_id", "plaid_secret", etc. +# Access values via: configuration.get_value(:client_id) or field.value +module Provider::Configurable + extend ActiveSupport::Concern + + class_methods do + # Define configuration for this provider + def configure(&block) + @configuration = Configuration.new(provider_key) + @configuration.instance_eval(&block) + Provider::ConfigurationRegistry.register(provider_key, @configuration, self) + end + + # Get the configuration for this provider + def configuration + @configuration || Provider::ConfigurationRegistry.get(provider_key) + end + + # Get the provider key (derived from class name) + # Example: Provider::PlaidAdapter -> "plaid" + def provider_key + name.demodulize.gsub(/Adapter$/, "").underscore + end + + # Get a configuration value + def config_value(field_name) + configuration&.get_value(field_name) + end + + # Check if provider is configured (all required fields present) + def configured? + configuration&.configured? || false + end + + # Reload provider-specific configuration (override in subclasses if needed) + # This is called after settings are updated in the UI + # Example: reload Rails.application.config values, reinitialize API clients, etc. + def reload_configuration + # Default implementation does nothing + # Override in provider adapters that need to reload configuration + end + end + + # Instance methods + def provider_key + self.class.provider_key + end + + def configuration + self.class.configuration + end + + def config_value(field_name) + self.class.config_value(field_name) + end + + def configured? + self.class.configured? + end + + # Configuration DSL + class Configuration + attr_reader :provider_key, :fields, :provider_description + + def initialize(provider_key) + @provider_key = provider_key + @fields = [] + @provider_description = nil + end + + # Set the provider-level description (markdown supported) + # @param text [String] The description text for this provider + def description(text) + @provider_description = text + end + + # Define a configuration field + # @param name [Symbol] The field name + # @param label [String] Human-readable label + # @param required [Boolean] Whether this field is required + # @param secret [Boolean] Whether this field contains sensitive data (will be masked in UI) + # @param env_key [String] The ENV variable key for this field + # @param default [String] Default value if none provided + # @param description [String] Optional help text + def field(name, label:, required: false, secret: false, env_key: nil, default: nil, description: nil) + @fields << ConfigField.new( + name: name, + label: label, + required: required, + secret: secret, + env_key: env_key, + default: default, + description: description, + provider_key: @provider_key + ) + end + + # Get value for a field (checks Setting, then ENV, then default) + def get_value(field_name) + field = fields.find { |f| f.name == field_name } + return nil unless field + + field.value + end + + # Check if all required fields are present + def configured? + fields.select(&:required).all? { |f| f.value.present? } + end + + # Get all field values as a hash + def to_h + fields.each_with_object({}) do |field, hash| + hash[field.name] = field.value + end + end + end + + # Represents a single configuration field + class ConfigField + attr_reader :name, :label, :required, :secret, :env_key, :default, :description, :provider_key + + def initialize(name:, label:, required:, secret:, env_key:, default:, description:, provider_key:) + @name = name + @label = label + @required = required + @secret = secret + @env_key = env_key + @default = default + @description = description + @provider_key = provider_key + end + + # Get the setting key for this field + # Example: plaid_client_id + def setting_key + "#{provider_key}_#{name}".to_sym + end + + # Get the value for this field (Setting -> ENV -> default) + def value + # First try Setting using dynamic hash-style access + # This works even without explicit field declarations in Setting model + setting_value = Setting[setting_key] + return normalize_value(setting_value) if setting_value.present? + + # Then try ENV if env_key is specified + if env_key.present? + env_value = ENV[env_key] + return normalize_value(env_value) if env_value.present? + end + + # Finally return default + normalize_value(default) + end + + # Check if this field has a value + def present? + value.present? + end + + # Validate the current value + # Returns true if valid, false otherwise + def valid? + validate.empty? + end + + # Get validation errors for the current value + # Returns an array of error messages + def validate + errors = [] + current_value = value + + # Required validation + if required && current_value.blank? + errors << "#{label} is required" + end + + # Additional validations can be added here in the future: + # - Format validation (regex) + # - Length validation + # - Enum validation + # - Custom validation blocks + + errors + end + + # Validate and raise an error if invalid + def validate! + errors = validate + raise ArgumentError, "Invalid configuration for #{setting_key}: #{errors.join(", ")}" if errors.any? + true + end + + private + # Normalize value by stripping whitespace and converting empty strings to nil + def normalize_value(val) + return nil if val.nil? + normalized = val.to_s.strip + normalized.empty? ? nil : normalized + end + end +end + +# Registry to store all provider configurations +module Provider::ConfigurationRegistry + class << self + def register(provider_key, configuration, adapter_class = nil) + registry[provider_key] = configuration + adapter_registry[provider_key] = adapter_class if adapter_class + end + + def get(provider_key) + registry[provider_key] + end + + def all + registry.values + end + + def providers + registry.keys + end + + # Get the adapter class for a provider key + def get_adapter_class(provider_key) + adapter_registry[provider_key] + end + + private + def registry + @registry ||= {} + end + + def adapter_registry + @adapter_registry ||= {} + end + end +end diff --git a/app/models/provider/factory.rb b/app/models/provider/factory.rb index 145963b35..cf8bae9b7 100644 --- a/app/models/provider/factory.rb +++ b/app/models/provider/factory.rb @@ -1,4 +1,6 @@ class Provider::Factory + class AdapterNotFoundError < StandardError; end + class << self # Register a provider adapter # @param provider_type [String] The provider account class name (e.g., "PlaidAccount") @@ -15,15 +17,9 @@ class Provider::Factory return nil if provider_account.nil? provider_type = provider_account.class.name - adapter_class = registry[provider_type] + adapter_class = find_adapter_class(provider_type) - # If not registered, try to load the adapter - if adapter_class.nil? - ensure_adapters_loaded - adapter_class = registry[provider_type] - end - - raise ArgumentError, "Unknown provider type: #{provider_type}. Did you forget to register it?" unless adapter_class + raise AdapterNotFoundError, "No adapter registered for provider type: #{provider_type}" unless adapter_class adapter_class.new(provider_account, account: account) end @@ -41,7 +37,35 @@ class Provider::Factory # @return [Array] List of registered provider type names def registered_provider_types ensure_adapters_loaded - registry.keys + registry.keys.sort + end + + # Ensures all provider adapters are loaded and registered + # Uses Rails autoloading to discover adapters dynamically + def ensure_adapters_loaded + # Eager load all adapter files to trigger their registration + adapter_files.each do |adapter_name| + adapter_class_name = "Provider::#{adapter_name}" + + # Use Rails autoloading (constantize) instead of require + begin + adapter_class_name.constantize + rescue NameError => e + Rails.logger.warn("Failed to load adapter: #{adapter_class_name} - #{e.message}") + end + end + end + + # Check if a provider type has a registered adapter + # @param provider_type [String] The provider account class name + # @return [Boolean] + def registered?(provider_type) + find_adapter_class(provider_type).present? + end + + # Clear all registered adapters (useful for testing) + def clear_registry! + @registry = {} end private @@ -50,17 +74,28 @@ class Provider::Factory @registry ||= {} end - # Ensures all provider adapters are loaded - # This is needed for Rails autoloading in development/test environments - def ensure_adapters_loaded - return if @adapters_loaded + # Find adapter class, attempting to load all adapters if not registered + def find_adapter_class(provider_type) + # Return if already registered + return registry[provider_type] if registry[provider_type] - # Require all adapter files to trigger registration - Dir[Rails.root.join("app/models/provider/*_adapter.rb")].each do |file| - require_dependency file + # Load all adapters to ensure they're registered + # This triggers their self-registration calls + ensure_adapters_loaded + + # Check registry again after loading + registry[provider_type] + end + + # Discover all adapter files in the provider directory + # Returns adapter class names (e.g., ["PlaidAdapter", "SimplefinAdapter"]) + def adapter_files + return [] unless defined?(Rails) + + pattern = Rails.root.join("app/models/provider/*_adapter.rb") + Dir[pattern].map do |file| + File.basename(file, ".rb").camelize end - - @adapters_loaded = true end end end diff --git a/app/models/provider/plaid_adapter.rb b/app/models/provider/plaid_adapter.rb index 4503ba8d6..0e60a4e86 100644 --- a/app/models/provider/plaid_adapter.rb +++ b/app/models/provider/plaid_adapter.rb @@ -1,14 +1,72 @@ +# PlaidAdapter serves dual purposes: +# +# 1. Configuration Manager (class-level): +# - Manages Rails.application.config.plaid (US region) +# - Exposes 3 configurable fields in "Plaid" section of settings UI +# - PlaidEuAdapter separately manages EU region in "Plaid Eu" section +# +# 2. Instance Adapter (instance-level): +# - Wraps ALL PlaidAccount instances regardless of region (US or EU) +# - The PlaidAccount's plaid_item.plaid_region determines which config to use +# - Delegates to Provider::Registry.plaid_provider_for_region(region) class Provider::PlaidAdapter < Provider::Base include Provider::Syncable include Provider::InstitutionMetadata + include Provider::Configurable - # Register this adapter with the factory + # Register this adapter with the factory for ALL PlaidAccount instances Provider::Factory.register("PlaidAccount", self) + # Configuration for Plaid US + configure do + description <<~DESC + Setup instructions: + 1. Visit the [Plaid Dashboard](https://dashboard.plaid.com/team/keys) to get your API credentials + 2. Your Client ID and Secret Key are required to enable Plaid bank sync for US/CA banks + 3. For production use, set environment to 'production', for testing use 'sandbox' + DESC + + field :client_id, + label: "Client ID", + required: false, + env_key: "PLAID_CLIENT_ID", + description: "Your Plaid Client ID from the Plaid Dashboard" + + field :secret, + label: "Secret Key", + required: false, + secret: true, + env_key: "PLAID_SECRET", + description: "Your Plaid Secret from the Plaid Dashboard" + + field :environment, + label: "Environment", + required: false, + env_key: "PLAID_ENV", + default: "sandbox", + description: "Plaid environment: sandbox, development, or production" + end + def provider_name "plaid" end + # Reload Plaid US configuration when settings are updated + def self.reload_configuration + client_id = config_value(:client_id).presence || ENV["PLAID_CLIENT_ID"] + secret = config_value(:secret).presence || ENV["PLAID_SECRET"] + environment = config_value(:environment).presence || ENV["PLAID_ENV"] || "sandbox" + + if client_id.present? && secret.present? + Rails.application.config.plaid = Plaid::Configuration.new + Rails.application.config.plaid.server_index = Plaid::Configuration::Environment[environment] + Rails.application.config.plaid.api_key["PLAID-CLIENT-ID"] = client_id + Rails.application.config.plaid.api_key["PLAID-SECRET"] = secret + else + Rails.application.config.plaid = nil + end + end + def sync_path Rails.application.routes.url_helpers.sync_plaid_item_path(item) end diff --git a/app/models/provider/plaid_eu_adapter.rb b/app/models/provider/plaid_eu_adapter.rb new file mode 100644 index 000000000..36c2eae4e --- /dev/null +++ b/app/models/provider/plaid_eu_adapter.rb @@ -0,0 +1,61 @@ +# PlaidEuAdapter is a configuration-only manager for Plaid EU credentials. +# +# It does NOT register as a provider type because: +# - There's no separate "PlaidEuAccount" model +# - All PlaidAccounts (regardless of region) use PlaidAdapter as their instance adapter +# +# This class only manages Rails.application.config.plaid_eu, which +# Provider::Registry.plaid_provider_for_region(:eu) uses to create Provider::Plaid instances. +# +# This separation into a distinct adapter class provides: +# - Clear UI separation: "Plaid" vs "Plaid Eu" sections in settings +# - Better UX: Users only configure the region they need +class Provider::PlaidEuAdapter + include Provider::Configurable + + # Configuration for Plaid EU + configure do + description <<~DESC + Setup instructions: + 1. Visit the [Plaid Dashboard](https://dashboard.plaid.com/team/keys) to get your API credentials + 2. Your Client ID and Secret Key are required to enable Plaid bank sync for European banks + 3. For production use, set environment to 'production', for testing use 'sandbox' + DESC + + field :client_id, + label: "Client ID", + required: false, + env_key: "PLAID_EU_CLIENT_ID", + description: "Your Plaid Client ID from the Plaid Dashboard for EU region" + + field :secret, + label: "Secret Key", + required: false, + secret: true, + env_key: "PLAID_EU_SECRET", + description: "Your Plaid Secret from the Plaid Dashboard for EU region" + + field :environment, + label: "Environment", + required: false, + env_key: "PLAID_EU_ENV", + default: "sandbox", + description: "Plaid environment: sandbox, development, or production" + end + + # Reload Plaid EU configuration when settings are updated + def self.reload_configuration + client_id = config_value(:client_id).presence || ENV["PLAID_EU_CLIENT_ID"] + secret = config_value(:secret).presence || ENV["PLAID_EU_SECRET"] + environment = config_value(:environment).presence || ENV["PLAID_EU_ENV"] || "sandbox" + + if client_id.present? && secret.present? + Rails.application.config.plaid_eu = Plaid::Configuration.new + Rails.application.config.plaid_eu.server_index = Plaid::Configuration::Environment[environment] + Rails.application.config.plaid_eu.api_key["PLAID-CLIENT-ID"] = client_id + Rails.application.config.plaid_eu.api_key["PLAID-SECRET"] = secret + else + Rails.application.config.plaid_eu = nil + end + end +end diff --git a/app/models/provider/simplefin_adapter.rb b/app/models/provider/simplefin_adapter.rb index 9cd758347..2a89fc89a 100644 --- a/app/models/provider/simplefin_adapter.rb +++ b/app/models/provider/simplefin_adapter.rb @@ -1,10 +1,27 @@ class Provider::SimplefinAdapter < Provider::Base include Provider::Syncable include Provider::InstitutionMetadata + include Provider::Configurable # Register this adapter with the factory Provider::Factory.register("SimplefinAccount", self) + # Configuration for SimpleFIN + configure do + description <<~DESC + Setup instructions: + 1. Visit [SimpleFIN Bridge](https://bridge.simplefin.org/simplefin/create) to get a setup token + 2. This token is optional and only needed if you want to provide a default setup token for users + DESC + + field :setup_token, + label: "Setup Token", + required: false, + secret: true, + env_key: "SIMPLEFIN_SETUP_TOKEN", + description: "Optional: SimpleFIN setup token from your SimpleFIN Bridge account (one-time use)" + end + def provider_name "simplefin" end diff --git a/app/models/setting.rb b/app/models/setting.rb index afffd70f2..043b17895 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -4,12 +4,18 @@ class Setting < RailsSettings::Base cache_prefix { "v1" } + # Third-party API keys field :twelve_data_api_key, type: :string, default: ENV["TWELVE_DATA_API_KEY"] field :openai_access_token, type: :string, default: ENV["OPENAI_ACCESS_TOKEN"] field :openai_uri_base, type: :string, default: ENV["OPENAI_URI_BASE"] field :openai_model, type: :string, default: ENV["OPENAI_MODEL"] field :brand_fetch_client_id, type: :string, default: ENV["BRAND_FETCH_CLIENT_ID"] + # Single hash field for all dynamic provider credentials and other dynamic settings + # This allows unlimited dynamic fields without declaring them upfront + field :dynamic_fields, type: :hash, default: {} + + # Onboarding and app settings ONBOARDING_STATES = %w[open closed invite_only].freeze DEFAULT_ONBOARDING_STATE = begin env_value = ENV["ONBOARDING_STATE"].to_s.presence || "open" @@ -42,6 +48,56 @@ class Setting < RailsSettings::Base self.require_invite_for_signup = state == "invite_only" self.raw_onboarding_state = state end + + # Support dynamic field access via bracket notation + # First checks if it's a declared field, then falls back to dynamic_fields hash + def [](key) + key_str = key.to_s + + # Check if it's a declared field first + if respond_to?(key_str) + public_send(key_str) + else + # Fall back to dynamic_fields hash + dynamic_fields[key_str] + end + end + + def []=(key, value) + key_str = key.to_s + + # If it's a declared field, use the setter + if respond_to?("#{key_str}=") + public_send("#{key_str}=", value) + else + # Otherwise, store in dynamic_fields hash + current_dynamic = dynamic_fields.dup + current_dynamic[key_str] = value + self.dynamic_fields = current_dynamic + end + end + + # Check if a dynamic field exists (useful to distinguish nil value vs missing key) + def key?(key) + key_str = key.to_s + respond_to?(key_str) || dynamic_fields.key?(key_str) + end + + # Delete a dynamic field + def delete(key) + key_str = key.to_s + return nil if respond_to?(key_str) # Can't delete declared fields + + current_dynamic = dynamic_fields.dup + value = current_dynamic.delete(key_str) + self.dynamic_fields = current_dynamic + value + end + + # List all dynamic field keys (excludes declared fields) + def dynamic_keys + dynamic_fields.keys + end end # Validates OpenAI configuration requires model when custom URI base is set diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb index c2803156e..43510e849 100644 --- a/app/views/settings/_settings_nav.html.erb +++ b/app/views/settings/_settings_nav.html.erb @@ -28,6 +28,7 @@ nav_sections = [ { label: "LLM Usage", path: settings_llm_usage_path, icon: "activity" }, { label: t(".api_keys_label"), path: settings_api_key_path, icon: "key" }, { label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? }, + { label: "Providers", path: settings_providers_path, icon: "plug" }, { label: t(".imports_label"), path: imports_path, icon: "download" }, { label: "SimpleFin", path: simplefin_items_path, icon: "building-2" } ] diff --git a/app/views/settings/providers/_provider_form.html.erb b/app/views/settings/providers/_provider_form.html.erb new file mode 100644 index 000000000..8f83a8f2e --- /dev/null +++ b/app/views/settings/providers/_provider_form.html.erb @@ -0,0 +1,83 @@ +<% + # Parameters: + # - configuration: Provider::Configurable::Configuration object +%> + +
+
+ <% if configuration.provider_description.present? %> +
+ <%= markdown(configuration.provider_description).html_safe %> +
+ <% end %> + + <% env_configured = configuration.fields.any? { |f| f.env_key && ENV[f.env_key].present? } %> + <% if env_configured %> +

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

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

Field descriptions:

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

Configured and ready to use

+
+ <% end %> +
diff --git a/app/views/settings/providers/show.html.erb b/app/views/settings/providers/show.html.erb new file mode 100644 index 000000000..028a85285 --- /dev/null +++ b/app/views/settings/providers/show.html.erb @@ -0,0 +1,15 @@ +<%= content_for :page_title, "Bank Sync Providers" %> + +
+
+

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

+
+ + <% @provider_configurations.each do |config| %> + <%= settings_section title: config.provider_key.titleize do %> + <%= render "settings/providers/provider_form", configuration: config %> + <% end %> + <% end %> +
diff --git a/config/initializers/plaid.rb b/config/initializers/plaid.rb index 1925158a0..f5c47fd2b 100644 --- a/config/initializers/plaid.rb +++ b/config/initializers/plaid.rb @@ -1,18 +1,15 @@ Rails.application.configure do + # Initialize Plaid configuration to nil config.plaid = nil config.plaid_eu = nil - - if ENV["PLAID_CLIENT_ID"].present? && ENV["PLAID_SECRET"].present? - config.plaid = Plaid::Configuration.new - config.plaid.server_index = Plaid::Configuration::Environment[ENV["PLAID_ENV"] || "sandbox"] - config.plaid.api_key["PLAID-CLIENT-ID"] = ENV["PLAID_CLIENT_ID"] - config.plaid.api_key["PLAID-SECRET"] = ENV["PLAID_SECRET"] - end - - if ENV["PLAID_EU_CLIENT_ID"].present? && ENV["PLAID_EU_SECRET"].present? - config.plaid_eu = Plaid::Configuration.new - config.plaid_eu.server_index = Plaid::Configuration::Environment[ENV["PLAID_ENV"] || "sandbox"] - config.plaid_eu.api_key["PLAID-CLIENT-ID"] = ENV["PLAID_EU_CLIENT_ID"] - config.plaid_eu.api_key["PLAID-SECRET"] = ENV["PLAID_EU_SECRET"] - end +end + +# Load Plaid configuration from adapters after initialization +Rails.application.config.after_initialize do + # Ensure provider adapters are loaded + Provider::Factory.ensure_adapters_loaded + + # Reload configurations from settings/ENV + Provider::PlaidAdapter.reload_configuration # US region + Provider::PlaidEuAdapter.reload_configuration # EU region end diff --git a/config/routes.rb b/config/routes.rb index 645c16d9c..a48449f72 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -76,6 +76,7 @@ Rails.application.routes.draw do resource :llm_usage, only: :show resource :guides, only: :show resource :bank_sync, only: :show, controller: "bank_sync" + resource :providers, only: %i[show update] end resource :subscription, only: %i[new show create] do diff --git a/test/models/provider/registry_test.rb b/test/models/provider/registry_test.rb index 5083a4b4a..e30c7d139 100644 --- a/test/models/provider/registry_test.rb +++ b/test/models/provider/registry_test.rb @@ -3,13 +3,14 @@ require "test_helper" class Provider::RegistryTest < ActiveSupport::TestCase test "providers filters out nil values when provider is not configured" do # Ensure OpenAI is not configured - Setting.stubs(:openai_access_token).returns(nil) - ENV.stubs(:fetch).with("OPENAI_ACCESS_TOKEN", nil).returns(nil) + ClimateControl.modify("OPENAI_ACCESS_TOKEN" => nil) do + Setting.stubs(:openai_access_token).returns(nil) - registry = Provider::Registry.for_concept(:llm) + registry = Provider::Registry.for_concept(:llm) - # Should return empty array instead of [nil] - assert_equal [], registry.providers + # Should return empty array instead of [nil] + assert_equal [], registry.providers + end end test "providers returns configured providers" do @@ -34,13 +35,14 @@ class Provider::RegistryTest < ActiveSupport::TestCase test "get_provider returns nil when provider not configured" do # Ensure OpenAI is not configured - Setting.stubs(:openai_access_token).returns(nil) - ENV.stubs(:[]).with("OPENAI_ACCESS_TOKEN").returns(nil) + ClimateControl.modify("OPENAI_ACCESS_TOKEN" => nil) do + Setting.stubs(:openai_access_token).returns(nil) - registry = Provider::Registry.for_concept(:llm) + registry = Provider::Registry.for_concept(:llm) - # Should return nil when provider method exists but returns nil - assert_nil registry.get_provider(:openai) + # Should return nil when provider method exists but returns nil + assert_nil registry.get_provider(:openai) + end end test "openai provider falls back to Setting when ENV is empty string" do