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

@@ -0,0 +1,156 @@
defmodule Berrypod.KeyValidation do
@moduledoc """
Lightweight format validation for API keys.
Auto-strips whitespace and checks known formats before attempting
network calls, giving users helpful error messages instead of
cryptic "connection failed" responses.
All functions return `{:ok, trimmed_key}` or `{:error, message}`.
"""
@doc """
Validates a Stripe secret key.
Checks for the `sk_test_` or `sk_live_` prefix. Detects common
mistakes like pasting a publishable key or restricted key.
"""
def validate_stripe_key(key) do
key = trim(key)
cond do
key == "" ->
{:error, "Please enter your Stripe secret key"}
String.starts_with?(key, "pk_") ->
{:error,
"This looks like a publishable key (starts with pk_). You need the secret key, which starts with sk_test_ or sk_live_"}
String.starts_with?(key, "rk_") ->
{:error,
"This looks like a restricted key (starts with rk_). You need the secret key, which starts with sk_test_ or sk_live_"}
not String.starts_with?(key, "sk_test_") and not String.starts_with?(key, "sk_live_") ->
{:error,
"Stripe secret keys start with sk_test_ or sk_live_. Check you're copying the right key from the Stripe dashboard"}
String.length(key) < 20 ->
{:error, "This key looks too short. Check you copied the full key from Stripe"}
true ->
{:ok, key}
end
end
@doc """
Validates a print provider API key (Printify, Printful, etc.).
Provider tokens are opaque, so we just check for empty/too-short values.
"""
def validate_provider_key(key, _provider_type \\ nil) do
key = trim(key)
cond do
key == "" ->
{:error, "Please enter your API token"}
String.length(key) < 10 ->
{:error, "This looks too short for an API token. Check you copied the full value"}
true ->
{:ok, key}
end
end
@doc """
Validates an email provider API key or secret.
Checks format for providers with known key patterns.
Falls through to basic validation for unknown providers.
"""
def validate_email_key(key, adapter_key, field_key) do
key = trim(key)
if key == "" do
{:error, "Please enter your API key"}
else
validate_email_format(key, adapter_key, field_key)
end
end
# ── Email provider format checks ──
# SendGrid: SG.{id}.{secret}
defp validate_email_format(key, "sendgrid", "api_key") do
cond do
String.starts_with?(key, "SG.") ->
{:ok, key}
String.contains?(key, ".") ->
{:error, "SendGrid keys start with SG. Check you copied the full key"}
true ->
{:error,
"SendGrid API keys start with SG. (like SG.xxxxx.xxxxx). This doesn't look right"}
end
end
# Postmark: UUID format
defp validate_email_format(key, "postmark", "api_key") do
uuid_pattern = ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
if Regex.match?(uuid_pattern, key) do
{:ok, key}
else
{:error,
"Postmark tokens are UUIDs (like abc12345-abcd-1234-abcd-123456789abc). Check you copied the server token, not the account token ID"}
end
end
# Resend: re_ prefix
defp validate_email_format(key, "resend", "api_key") do
if String.starts_with?(key, "re_") do
{:ok, key}
else
{:error, "Resend API keys start with re_. Check you copied the full key"}
end
end
# Mailgun: key- prefix (classic keys)
defp validate_email_format(key, "mailgun", "api_key") do
cond do
String.starts_with?(key, "key-") ->
{:ok, key}
# Newer RBAC keys don't have a known prefix, let them through
String.length(key) >= 20 ->
{:ok, key}
true ->
{:error, "Mailgun API keys usually start with key-. Check you copied the full key"}
end
end
# Brevo: xkeysib- prefix
defp validate_email_format(key, "brevo", "api_key") do
if String.starts_with?(key, "xkeysib-") do
{:ok, key}
else
{:error, "Brevo API keys start with xkeysib-. Check you copied the full key"}
end
end
# Unknown provider or non-api_key field, basic length check only
defp validate_email_format(key, _adapter_key, _field_key) do
if String.length(key) < 5 do
{:error, "This value looks too short. Check you copied the full key"}
else
{:ok, key}
end
end
# ── Helpers ──
defp trim(nil), do: ""
defp trim(key) when is_binary(key), do: String.trim(key)
end