feat: add encrypted settings, guided Stripe setup, and admin credentials page
Store API keys and secrets encrypted in the SQLite database via the existing Vault module (AES-256-GCM). The only external dependency is SECRET_KEY_BASE — everything else lives in the portable DB file. - Add encrypted_value column to settings table with new "encrypted" type - Add put_secret/get_secret/delete_setting/secret_hint to Settings context - Add Secrets module to load encrypted config into Application env at startup - Add Stripe.Setup module with connect/disconnect/verify_api_key flow - Auto-creates webhook endpoints via Stripe API in production - Detects localhost and shows Stripe CLI instructions for dev - Add admin credentials page at /admin/settings with guided setup: - Not configured: single Secret key input with dashboard link - Connected (production): status display, webhook info, disconnect - Connected (dev): Stripe CLI instructions, manual signing secret input - Remove Stripe env vars from dev.exs and runtime.exs - Fix CSSCache test startup crash (handle_continue instead of init) - Add nav link for Credentials page 507 tests, 0 failures. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,9 @@
|
||||
<li>
|
||||
<.link href={~p"/admin/theme"}>Theme</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link href={~p"/admin/settings"}>Credentials</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link href={~p"/users/settings"}>Settings</.link>
|
||||
</li>
|
||||
|
||||
283
lib/simpleshop_theme_web/live/admin_live/settings.ex
Normal file
283
lib/simpleshop_theme_web/live/admin_live/settings.ex
Normal file
@@ -0,0 +1,283 @@
|
||||
defmodule SimpleshopThemeWeb.AdminLive.Settings do
|
||||
use SimpleshopThemeWeb, :live_view
|
||||
|
||||
alias SimpleshopTheme.Settings
|
||||
alias SimpleshopTheme.Stripe.Setup, as: StripeSetup
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, socket |> assign(:page_title, "Credentials") |> assign_stripe_state()}
|
||||
end
|
||||
|
||||
defp assign_stripe_state(socket) do
|
||||
has_key = Settings.has_secret?("stripe_api_key")
|
||||
has_signing = Settings.has_secret?("stripe_signing_secret")
|
||||
|
||||
status =
|
||||
cond do
|
||||
!has_key -> :not_configured
|
||||
has_key && StripeSetup.localhost?() -> :connected_localhost
|
||||
true -> :connected
|
||||
end
|
||||
|
||||
socket
|
||||
|> assign(:stripe_status, status)
|
||||
|> assign(:stripe_api_key_hint, Settings.secret_hint("stripe_api_key"))
|
||||
|> assign(:stripe_signing_secret_hint, Settings.secret_hint("stripe_signing_secret"))
|
||||
|> assign(:stripe_webhook_url, StripeSetup.webhook_url())
|
||||
|> assign(:stripe_has_signing_secret, has_signing)
|
||||
|> assign(:connect_form, to_form(%{"api_key" => ""}, as: :stripe))
|
||||
|> assign(:secret_form, to_form(%{"signing_secret" => ""}, as: :webhook))
|
||||
|> assign(:advanced_open, false)
|
||||
|> assign(:connecting, false)
|
||||
end
|
||||
|
||||
@impl true
|
||||
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, :connecting, true)
|
||||
|
||||
case StripeSetup.connect(api_key) do
|
||||
{:ok, :webhook_created} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign_stripe_state()
|
||||
|> put_flash(:info, "Stripe connected and webhook endpoint created")
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:ok, :localhost} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign_stripe_state()
|
||||
|> put_flash(
|
||||
:info,
|
||||
"API key saved. Enter a webhook signing secret below for local testing."
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, message} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:connecting, false)
|
||||
|> put_flash(:error, "Stripe connection failed: #{message}")
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("disconnect_stripe", _params, socket) do
|
||||
StripeSetup.disconnect()
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign_stripe_state()
|
||||
|> put_flash(:info, "Stripe disconnected")
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("save_signing_secret", %{"webhook" => %{"signing_secret" => secret}}, socket) do
|
||||
if secret == "" do
|
||||
{:noreply, put_flash(socket, :error, "Please enter a signing secret")}
|
||||
else
|
||||
StripeSetup.save_signing_secret(secret)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign_stripe_state()
|
||||
|> put_flash(:info, "Webhook signing secret saved")
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("toggle_advanced", _params, socket) do
|
||||
{:noreply, assign(socket, :advanced_open, !socket.assigns.advanced_open)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="max-w-2xl">
|
||||
<.header>
|
||||
Credentials
|
||||
<:subtitle>Connect payment providers and manage API keys</:subtitle>
|
||||
</.header>
|
||||
|
||||
<section class="mt-10">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold">Stripe</h2>
|
||||
<%= case @stripe_status do %>
|
||||
<% :connected -> %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-green-600/20 ring-inset">
|
||||
<.icon name="hero-check-circle-mini" class="size-3" /> Connected
|
||||
</span>
|
||||
<% :connected_localhost -> %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700 ring-1 ring-amber-600/20 ring-inset">
|
||||
<.icon name="hero-exclamation-triangle-mini" class="size-3" /> Dev mode
|
||||
</span>
|
||||
<% :not_configured -> %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-zinc-50 px-2 py-1 text-xs font-medium text-zinc-600 ring-1 ring-zinc-500/10 ring-inset">
|
||||
Not connected
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if @stripe_status == :not_configured do %>
|
||||
<.stripe_setup_form connect_form={@connect_form} connecting={@connecting} />
|
||||
<% else %>
|
||||
<.stripe_connected_view
|
||||
stripe_status={@stripe_status}
|
||||
stripe_api_key_hint={@stripe_api_key_hint}
|
||||
stripe_webhook_url={@stripe_webhook_url}
|
||||
stripe_signing_secret_hint={@stripe_signing_secret_hint}
|
||||
stripe_has_signing_secret={@stripe_has_signing_secret}
|
||||
secret_form={@secret_form}
|
||||
advanced_open={@advanced_open}
|
||||
/>
|
||||
<% end %>
|
||||
</section>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
defp stripe_setup_form(assigns) do
|
||||
~H"""
|
||||
<div class="mt-4">
|
||||
<p class="text-sm text-zinc-600">
|
||||
To accept payments, connect your Stripe account by entering your secret key.
|
||||
You can find it in your
|
||||
<a
|
||||
href="https://dashboard.stripe.com/apikeys"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-zinc-900 underline"
|
||||
>
|
||||
Stripe dashboard
|
||||
</a>
|
||||
under Developers → API keys.
|
||||
</p>
|
||||
|
||||
<.form for={@connect_form} phx-submit="connect_stripe" class="mt-6">
|
||||
<.input
|
||||
field={@connect_form[:api_key]}
|
||||
type="password"
|
||||
label="Secret key"
|
||||
autocomplete="off"
|
||||
placeholder="sk_test_... or sk_live_..."
|
||||
/>
|
||||
<p class="text-xs text-zinc-500 mt-1">
|
||||
Starts with <code>sk_test_</code> (test mode) or <code>sk_live_</code> (live mode).
|
||||
This key is encrypted at rest in the database.
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<.button phx-disable-with="Connecting...">
|
||||
Connect Stripe
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp stripe_connected_view(assigns) do
|
||||
~H"""
|
||||
<div class="mt-4 space-y-4">
|
||||
<dl class="text-sm">
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-zinc-500 w-28 shrink-0">API key</dt>
|
||||
<dd><code class="text-zinc-700">{@stripe_api_key_hint}</code></dd>
|
||||
</div>
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-zinc-500 w-28 shrink-0">Webhook URL</dt>
|
||||
<dd><code class="text-zinc-700 text-xs break-all">{@stripe_webhook_url}</code></dd>
|
||||
</div>
|
||||
<div class="flex gap-2 py-1">
|
||||
<dt class="text-zinc-500 w-28 shrink-0">Webhook secret</dt>
|
||||
<dd>
|
||||
<%= if @stripe_has_signing_secret do %>
|
||||
<code class="text-zinc-700">{@stripe_signing_secret_hint}</code>
|
||||
<% else %>
|
||||
<span class="text-amber-600">Not set</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<%= if @stripe_status == :connected_localhost do %>
|
||||
<div class="rounded-md bg-amber-50 p-4 ring-1 ring-amber-600/10 ring-inset">
|
||||
<p class="text-sm text-amber-800">
|
||||
Stripe can't reach localhost for webhooks. For local testing, run the Stripe CLI:
|
||||
</p>
|
||||
<pre class="mt-2 rounded bg-amber-100 p-2 text-xs text-amber-900 overflow-x-auto">stripe listen --forward-to localhost:4000/webhooks/stripe</pre>
|
||||
<p class="mt-2 text-xs text-amber-700">
|
||||
The CLI will output a signing secret starting with <code>whsec_</code>. Enter it below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<.form for={@secret_form} phx-submit="save_signing_secret" class="mt-2">
|
||||
<.input
|
||||
field={@secret_form[:signing_secret]}
|
||||
type="password"
|
||||
label="Webhook signing secret"
|
||||
autocomplete="off"
|
||||
placeholder="whsec_..."
|
||||
/>
|
||||
<div class="mt-3">
|
||||
<.button phx-disable-with="Saving...">Save signing secret</.button>
|
||||
</div>
|
||||
</.form>
|
||||
<% else %>
|
||||
<div class="border-t border-zinc-200 pt-3">
|
||||
<button
|
||||
phx-click="toggle_advanced"
|
||||
class="flex items-center gap-1 text-sm text-zinc-500 hover:text-zinc-700"
|
||||
>
|
||||
<.icon
|
||||
name={if @advanced_open, do: "hero-chevron-down-mini", else: "hero-chevron-right-mini"}
|
||||
class="size-4"
|
||||
/> Advanced
|
||||
</button>
|
||||
|
||||
<%= if @advanced_open do %>
|
||||
<div class="mt-3">
|
||||
<p class="text-xs text-zinc-500 mb-3">
|
||||
Override the webhook signing secret if you need to use a custom endpoint or the Stripe CLI.
|
||||
</p>
|
||||
<.form for={@secret_form} phx-submit="save_signing_secret">
|
||||
<.input
|
||||
field={@secret_form[:signing_secret]}
|
||||
type="password"
|
||||
label="Webhook signing secret"
|
||||
autocomplete="off"
|
||||
placeholder="whsec_..."
|
||||
/>
|
||||
<div class="mt-3">
|
||||
<.button phx-disable-with="Saving...">Save signing secret</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="border-t border-zinc-200 pt-4">
|
||||
<button
|
||||
phx-click="disconnect_stripe"
|
||||
data-confirm="This will remove your Stripe API key and delete the webhook endpoint. Are you sure?"
|
||||
class="text-sm text-red-600 hover:text-red-800"
|
||||
>
|
||||
Disconnect Stripe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -117,6 +117,7 @@ defmodule SimpleshopThemeWeb.Router do
|
||||
live "/admin/providers", ProviderLive.Index, :index
|
||||
live "/admin/providers/new", ProviderLive.Form, :new
|
||||
live "/admin/providers/:id/edit", ProviderLive.Form, :edit
|
||||
live "/admin/settings", AdminLive.Settings, :index
|
||||
end
|
||||
|
||||
post "/users/update-password", UserSessionController, :update_password
|
||||
|
||||
Reference in New Issue
Block a user