berrypod/lib/berrypod_web/live/admin/providers/form.ex
jamey 76cff0494e add forgiving API key validation with inline errors
Add KeyValidation module for format-checking API keys before
attempting connections. Auto-strips whitespace, detects common
mistakes (e.g. pasting a Stripe publishable key), and returns
helpful error messages.

Inline field errors across all three entry points:
- Setup wizard: provider + Stripe keys
- Admin provider form: simplified to single Connect button
- Email settings: per-field errors instead of flash toasts

Also: plain text inputs for all API keys (not password fields),
accessible error states (aria-invalid, role=alert, thick border,
bold text), inner_block slot declaration on error component.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:17:56 +00:00

112 lines
3.6 KiB
Elixir

defmodule BerrypodWeb.Admin.Providers.Form do
use BerrypodWeb, :live_view
alias Berrypod.{KeyValidation, Products}
alias Berrypod.Products.ProviderConnection
alias Berrypod.Providers.Provider
@supported_types Enum.map(Provider.available(), & &1.type)
@impl true
def mount(params, _session, socket) do
{:ok, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :new, params) do
provider_type = validated_type(params["type"])
provider = Provider.get(provider_type)
socket
|> assign(:page_title, "Connect to #{provider.name}")
|> assign(:provider_type, provider_type)
|> assign(:provider, provider)
|> assign(:connection, %ProviderConnection{provider_type: provider_type})
|> assign(:form, to_form(ProviderConnection.changeset(%ProviderConnection{}, %{})))
|> assign(:pending_api_key, nil)
end
defp apply_action(socket, :edit, %{"id" => id}) do
connection = Products.get_provider_connection!(id)
provider = Provider.get(connection.provider_type)
socket
|> assign(:page_title, "#{provider.name} settings")
|> assign(:provider_type, connection.provider_type)
|> assign(:provider, provider)
|> assign(:connection, connection)
|> assign(:form, to_form(ProviderConnection.changeset(connection, %{})))
|> assign(:pending_api_key, nil)
end
@impl true
def handle_event("validate", %{"provider_connection" => params}, socket) do
form =
socket.assigns.connection
|> ProviderConnection.changeset(params)
|> Map.put(:action, :validate)
|> to_form()
# Store api_key separately since changeset encrypts it immediately
{:noreply, assign(socket, form: form, pending_api_key: params["api_key"])}
end
@impl true
def handle_event("save", %{"provider_connection" => params}, socket) do
save_connection(socket, socket.assigns.live_action, params)
end
defp save_connection(socket, :new, params) do
api_key = params["api_key"] || socket.assigns[:pending_api_key]
provider_type = socket.assigns.provider_type
case KeyValidation.validate_provider_key(api_key, provider_type) do
{:error, message} ->
form = form_with_api_key_error(socket, api_key, message)
{:noreply, assign(socket, :form, form)}
{:ok, api_key} ->
case Products.connect_provider(api_key, provider_type) do
{:ok, _connection} ->
{:noreply,
socket
|> put_flash(:info, "Connected to #{socket.assigns.provider.name}!")
|> push_navigate(to: ~p"/admin/settings")}
{:error, _reason} ->
form =
form_with_api_key_error(
socket,
api_key,
"Could not connect. Check your API key and try again"
)
{:noreply, assign(socket, :form, form)}
end
end
end
defp save_connection(socket, :edit, params) do
case Products.update_provider_connection(socket.assigns.connection, params) do
{:ok, _connection} ->
{:noreply,
socket
|> put_flash(:info, "Settings saved")
|> push_navigate(to: ~p"/admin/settings")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
defp form_with_api_key_error(socket, api_key, message) do
socket.assigns.connection
|> ProviderConnection.changeset(%{"api_key" => api_key})
|> Ecto.Changeset.add_error(:api_key, message)
|> Map.put(:action, :validate)
|> to_form()
end
defp validated_type(type) when type in @supported_types, do: type
defp validated_type(_), do: "printify"
end