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,7 +1,7 @@
defmodule BerrypodWeb.Setup.Onboarding do
use BerrypodWeb, :live_view
alias Berrypod.{Accounts, Products, Settings, Setup}
alias Berrypod.{Accounts, KeyValidation, Products, Settings, Setup}
alias Berrypod.Providers.Provider
alias Berrypod.Stripe.Setup, as: StripeSetup
@@ -131,75 +131,114 @@ defmodule BerrypodWeb.Setup.Onboarding do
def handle_event("connect_provider", %{"provider" => %{"api_key" => api_key}}, socket) do
type = socket.assigns.selected_provider
if api_key == "" do
{:noreply, put_flash(socket, :error, "Please enter your API token")}
else
socket = assign(socket, provider_connecting: true)
case KeyValidation.validate_provider_key(api_key, type) do
{:error, message} ->
form =
to_form(%{"api_key" => api_key},
as: :provider,
errors: [api_key: {message, []}],
action: :validate
)
case Products.connect_provider(api_key, type) do
{:ok, connection} ->
setup = Setup.setup_status()
{:noreply, assign(socket, :provider_form, form)}
{:ok, api_key} ->
socket = assign(socket, :provider_connecting, true)
case Products.connect_provider(api_key, type) do
{:ok, connection} ->
setup = Setup.setup_status()
if setup.setup_complete do
{:noreply,
socket
|> put_flash(:info, "You're in! Here's your launch checklist.")
|> push_navigate(to: ~p"/admin")}
else
{:noreply,
socket
|> assign(:provider_connecting, false)
|> assign(:provider_conn, connection)
|> assign(:setup, setup)
|> put_flash(:info, "Connected! Product sync started in the background.")}
end
{:error, :no_api_key} ->
form =
to_form(%{"api_key" => api_key},
as: :provider,
errors: [api_key: {"Please enter your API token", []}],
action: :validate
)
if setup.setup_complete do
{:noreply,
socket
|> put_flash(:info, "You're in! Here's your launch checklist.")
|> push_navigate(to: ~p"/admin")}
else
{:noreply,
socket
|> assign(:provider_connecting, false)
|> assign(:provider_conn, connection)
|> assign(:setup, setup)
|> put_flash(:info, "Connected! Product sync started in the background.")}
end
|> assign(:provider_form, form)}
{:error, :no_api_key} ->
{:noreply,
socket
|> assign(:provider_connecting, false)
|> put_flash(:error, "Please enter your API token")}
{:error, _reason} ->
form =
to_form(%{"api_key" => api_key},
as: :provider,
errors: [api_key: {"Could not connect. Check your API key and try again", []}],
action: :validate
)
{:error, _reason} ->
{:noreply,
socket
|> assign(:provider_connecting, false)
|> put_flash(:error, "Could not connect — check your API key and try again")}
end
{:noreply,
socket
|> assign(:provider_connecting, false)
|> assign(:provider_form, form)}
end
end
end
# ── Events: Stripe ──
def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
if api_key == "" do
{:noreply, put_flash(socket, :error, "Please enter your Stripe secret key")}
else
socket = assign(socket, stripe_connecting: true)
case KeyValidation.validate_stripe_key(api_key) do
{:error, message} ->
form =
to_form(%{"api_key" => api_key},
as: :stripe,
errors: [api_key: {message, []}],
action: :validate
)
case StripeSetup.connect(api_key) do
{:ok, _result} ->
setup = Setup.setup_status()
{:noreply, assign(socket, :stripe_form, form)}
{:ok, api_key} ->
socket = assign(socket, :stripe_connecting, true)
case StripeSetup.connect(api_key) do
{:ok, _result} ->
setup = Setup.setup_status()
if setup.setup_complete do
{:noreply,
socket
|> put_flash(:info, "You're in! Here's your launch checklist.")
|> push_navigate(to: ~p"/admin")}
else
{:noreply,
socket
|> assign(:stripe_connecting, false)
|> assign(:setup, setup)
|> put_flash(:info, "Stripe connected")}
end
{:error, message} ->
form =
to_form(%{"api_key" => api_key},
as: :stripe,
errors: [api_key: {"Stripe connection failed: #{message}", []}],
action: :validate
)
if setup.setup_complete do
{:noreply,
socket
|> put_flash(:info, "You're in! Here's your launch checklist.")
|> push_navigate(to: ~p"/admin")}
else
{:noreply,
socket
|> assign(:stripe_connecting, false)
|> assign(:setup, setup)
|> put_flash(:info, "Stripe connected")}
end
{:error, message} ->
{:noreply,
socket
|> assign(:stripe_connecting, false)
|> put_flash(:error, "Stripe connection failed: #{message}")}
end
|> assign(:stripe_form, form)}
end
end
end
@@ -208,6 +247,7 @@ defmodule BerrypodWeb.Setup.Onboarding do
@impl true
def render(assigns) do
~H"""
<Layouts.flash_group flash={@flash} />
<div class="setup-page">
<div class="setup-header">
<h1 class="setup-title">Set up your shop</h1>
@@ -400,7 +440,7 @@ defmodule BerrypodWeb.Setup.Onboarding do
<.form for={@form} phx-submit="connect_provider">
<.input
field={@form[:api_key]}
type="password"
type="text"
label="API token"
placeholder="Paste your token here"
autocomplete="off"
@@ -436,15 +476,12 @@ defmodule BerrypodWeb.Setup.Onboarding do
<.form for={@form} phx-submit="connect_stripe">
<.input
field={@form[:api_key]}
type="password"
type="text"
label="Secret key"
autocomplete="off"
placeholder="sk_test_... or sk_live_..."
phx-mounted={@focus && JS.focus()}
/>
<p class="setup-key-hint">
Starts with <code>sk_test_</code> or <code>sk_live_</code>. Encrypted at rest.
</p>
<div class="setup-actions">
<.button phx-disable-with="Connecting...">
{if @connecting, do: "Connecting...", else: "Connect"}