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,9 +1,8 @@
defmodule BerrypodWeb.Admin.Providers.Form do
use BerrypodWeb, :live_view
alias Berrypod.Products
alias Berrypod.{KeyValidation, Products}
alias Berrypod.Products.ProviderConnection
alias Berrypod.Providers
alias Berrypod.Providers.Provider
@supported_types Enum.map(Provider.available(), & &1.type)
@@ -23,8 +22,6 @@ defmodule BerrypodWeb.Admin.Providers.Form do
|> assign(:provider, provider)
|> assign(:connection, %ProviderConnection{provider_type: provider_type})
|> assign(:form, to_form(ProviderConnection.changeset(%ProviderConnection{}, %{})))
|> assign(:testing, false)
|> assign(:test_result, nil)
|> assign(:pending_api_key, nil)
end
@@ -38,8 +35,6 @@ defmodule BerrypodWeb.Admin.Providers.Form do
|> assign(:provider, provider)
|> assign(:connection, connection)
|> assign(:form, to_form(ProviderConnection.changeset(connection, %{})))
|> assign(:testing, false)
|> assign(:test_result, nil)
|> assign(:pending_api_key, nil)
end
@@ -55,27 +50,6 @@ defmodule BerrypodWeb.Admin.Providers.Form do
{:noreply, assign(socket, form: form, pending_api_key: params["api_key"])}
end
@impl true
def handle_event("test_connection", _params, socket) do
socket = assign(socket, testing: true, test_result: nil)
api_key =
socket.assigns[:pending_api_key] ||
ProviderConnection.get_api_key(socket.assigns.connection)
if api_key && api_key != "" do
temp_conn = %ProviderConnection{
provider_type: socket.assigns.provider_type,
api_key_encrypted: encrypt_api_key(api_key)
}
result = Providers.test_connection(temp_conn)
{:noreply, assign(socket, testing: false, test_result: result)}
else
{:noreply, assign(socket, testing: false, test_result: {:error, :no_api_key})}
end
end
@impl true
def handle_event("save", %{"provider_connection" => params}, socket) do
save_connection(socket, socket.assigns.live_action, params)
@@ -85,15 +59,29 @@ defmodule BerrypodWeb.Admin.Providers.Form do
api_key = params["api_key"] || socket.assigns[:pending_api_key]
provider_type = socket.assigns.provider_type
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")}
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)}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Could not connect — check your API key")}
{: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
@@ -110,26 +98,14 @@ defmodule BerrypodWeb.Admin.Providers.Form do
end
end
defp encrypt_api_key(api_key) do
case Berrypod.Vault.encrypt(api_key) do
{:ok, encrypted} -> encrypted
_ -> nil
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"
# Shared helpers used by the template
defp connection_name({:ok, %{shop_name: name}}), do: name
defp connection_name({:ok, %{store_name: name}}), do: name
defp connection_name(_), do: nil
defp format_error(:no_api_key), do: "Please enter your API key"
defp format_error(:unauthorized), do: "That key doesn't seem to be valid"
defp format_error(:timeout), do: "Couldn't reach the provider - try again"
defp format_error({:http_error, _code}), do: "Something went wrong - try again"
defp format_error(error) when is_binary(error), do: error
defp format_error(_), do: "Connection failed - check your key and try again"
end

View File

@@ -32,7 +32,7 @@
<.input
field={@form[:api_key]}
type="password"
type="text"
label={"#{@provider.name} API key"}
placeholder={
if @live_action == :edit,
@@ -42,44 +42,14 @@
autocomplete="off"
/>
<div class="admin-inline-group">
<button
type="button"
class="admin-btn admin-btn-outline admin-btn-sm"
phx-click="test_connection"
disabled={@testing}
>
<.icon
name={if @testing, do: "hero-arrow-path", else: "hero-signal"}
class={if @testing, do: "size-4 animate-spin", else: "size-4"}
/>
{if @testing, do: "Checking...", else: "Check connection"}
</button>
<%= if @test_result do %>
<%= case @test_result do %>
<% {:ok, _info} -> %>
<span class="admin-status-success">
<.icon name="hero-check-circle" class="size-4" />
Connected to {connection_name(@test_result) || @provider.name}
</span>
<% {:error, reason} -> %>
<span class="admin-status-error">
<.icon name="hero-x-circle" class="size-4" />
{format_error(reason)}
</span>
<% end %>
<% end %>
</div>
<%= if @live_action == :edit do %>
<.input field={@form[:enabled]} type="checkbox" label="Connection enabled" />
<% end %>
<div class="admin-form-actions">
<.button type="submit" disabled={@testing}>
<.button type="submit">
{if @live_action == :new,
do: "Connect to #{@provider.name}",
do: "Connect",
else: "Save changes"}
</.button>
<.link navigate={~p"/admin/providers"} class="admin-btn admin-btn-ghost">