Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
95 changes: 95 additions & 0 deletions app/controllers/settings/providers_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
class Settings::ProvidersController < ApplicationController
layout "settings"

guard_feature unless: -> { self_hosted? }

before_action :ensure_admin, only: [ :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
updated_fields = []

# Dynamically update all provider settings based on permitted params
provider_params.each do |param_key, param_value|
setting_key = param_key.to_sym

# Clean the value
value = param_value.to_s.strip

# For secret fields, ignore placeholder values to prevent accidental overwrite
if value == "********"
next
end

# Set the value using dynamic hash-style access
# This works without explicit field declarations in Setting model
Setting[setting_key] = value
updated_fields << param_key
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
286 changes: 286 additions & 0 deletions app/models/provider/configurable.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading