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>
This commit is contained in:
jamey
2026-03-04 12:17:56 +00:00
parent e139a75b69
commit 76cff0494e
10 changed files with 557 additions and 216 deletions

View File

@@ -1,6 +1,7 @@
defmodule BerrypodWeb.Admin.EmailSettings do
use BerrypodWeb, :live_view
alias Berrypod.KeyValidation
alias Berrypod.Mailer
alias Berrypod.Mailer.Adapters
alias Berrypod.Settings
@@ -28,6 +29,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do
)
|> assign(:sending_test, false)
|> assign(:from_checklist, false)
|> assign(:field_errors, %{})
|> assign(:form, to_form(%{}, as: :email))}
end
@@ -71,7 +73,12 @@ defmodule BerrypodWeb.Admin.EmailSettings do
@impl true
def handle_event("change_adapter", %{"email" => %{"adapter" => key}}, socket) do
values = load_adapter_values(key)
{:noreply, socket |> assign(:adapter_key, key) |> assign(:current_values, values)}
{:noreply,
socket
|> assign(:adapter_key, key)
|> assign(:current_values, values)
|> assign(:field_errors, %{})}
end
def handle_event("save", %{"email" => params}, socket) do
@@ -137,6 +144,9 @@ defmodule BerrypodWeb.Admin.EmailSettings do
end
defp save_adapter_config(socket, adapter_info, params) do
# Trim all values
params = Map.new(params, fn {k, v} -> {k, if(is_binary(v), do: String.trim(v), else: v)} end)
# Validate required fields
missing =
adapter_info.fields
@@ -149,9 +159,29 @@ defmodule BerrypodWeb.Admin.EmailSettings do
empty and not (field.type == :secret and Settings.get_secret(settings_key) != nil)
end)
if missing != [] do
labels = Enum.map_join(missing, ", ", & &1.label)
{:noreply, put_flash(socket, :error, "Missing required fields: #{labels}")}
# Build per-field errors for missing required fields
missing_errors =
for field <- missing, into: %{} do
{field.key, "#{field.label} is required"}
end
# Validate secret field formats for known providers
format_errors =
for field <- adapter_info.fields,
field.type == :secret,
value = params[field.key],
value != nil and value != "",
{:error, message} <- [
KeyValidation.validate_email_key(value, adapter_info.key, field.key)
],
into: %{} do
{field.key, message}
end
field_errors = Map.merge(missing_errors, format_errors)
if field_errors != %{} do
{:noreply, assign(socket, :field_errors, field_errors)}
else
# Save adapter type
Settings.put_setting("email_adapter", adapter_info.key)
@@ -199,6 +229,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|> assign(:current_values, current_values)
|> assign(:from_address, from_address)
|> assign(:email_configured, Mailer.email_configured?())
|> assign(:field_errors, %{})
|> put_flash(:info, "Email settings saved")}
end
end
@@ -293,6 +324,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do
field_def={field}
value={if selected, do: @current_values[field.key]}
disabled={!selected || @env_locked}
error={if selected, do: @field_errors[field.key]}
/>
<% end %>
<%= unless @env_locked do %>
@@ -343,19 +375,23 @@ defmodule BerrypodWeb.Admin.EmailSettings do
attr :field_def, :map, required: true
attr :value, :any, default: nil
attr :disabled, :boolean, default: false
attr :error, :string, default: nil
defp adapter_field_static(%{field_def: %{type: :secret}} = assigns) do
assigns = assign(assigns, :errors, if(assigns.error, do: [assigns.error], else: []))
~H"""
<div>
<.input
name={"email[#{@field_def.key}]"}
value=""
type="password"
type="text"
label={@field_def.label}
autocomplete="off"
placeholder={if @value, do: @value, else: ""}
required={@field_def.required && !@value}
disabled={@disabled}
errors={@errors}
/>
<%= if @value && !@disabled do %>
<p class="admin-help-text">
@@ -367,6 +403,8 @@ defmodule BerrypodWeb.Admin.EmailSettings do
end
defp adapter_field_static(%{field_def: %{type: :integer}} = assigns) do
assigns = assign(assigns, :errors, if(assigns.error, do: [assigns.error], else: []))
~H"""
<.input
name={"email[#{@field_def.key}]"}
@@ -375,11 +413,14 @@ defmodule BerrypodWeb.Admin.EmailSettings do
label={@field_def.label}
required={@field_def.required}
disabled={@disabled}
errors={@errors}
/>
"""
end
defp adapter_field_static(assigns) do
assigns = assign(assigns, :errors, if(assigns.error, do: [assigns.error], else: []))
~H"""
<.input
name={"email[#{@field_def.key}]"}
@@ -388,6 +429,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do
label={@field_def.label}
required={@field_def.required}
disabled={@disabled}
errors={@errors}
/>
"""
end