forked from maybe-finance/maybe
-
Notifications
You must be signed in to change notification settings - Fork 97
Add support for dynamic config UI #256
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 5 commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
c400dfc
Add support for dynamic config UI
sokie 4b2ad60
Add support for section description
sokie 6c7fead
Better dynamic class settings
sokie fb540a4
FIX proper lookup for provider keys
sokie bc0be96
Fix factory
sokie 46e05b2
Make updates atomic, field-aware, and handle blanks explicitly
sokie aaa92aa
Small UX detail
sokie fc73b1d
Add support for PlaidEU in UI also
sokie File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
sokie marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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 | ||
sokie marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| # 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
jjmata marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| end | ||
| end | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.