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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user