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:
jamey
2026-02-07 17:12:53 +00:00
parent ff1bc483b9
commit eede9bb517
15 changed files with 829 additions and 19 deletions

View File

@@ -12,6 +12,8 @@ defmodule SimpleshopTheme.Application do
SimpleshopTheme.Repo,
{Ecto.Migrator,
repos: Application.fetch_env!(:simpleshop_theme, :ecto_repos), skip: skip_migrations?()},
# Load encrypted secrets from DB into Application env
{Task, &SimpleshopTheme.Secrets.load_all/0},
{DNSCluster, query: Application.get_env(:simpleshop_theme, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: SimpleshopTheme.PubSub},
# Background job processing

View File

@@ -0,0 +1,44 @@
defmodule SimpleshopTheme.Secrets do
@moduledoc """
Loads encrypted secrets from the database into Application env at runtime.
Secrets are stored encrypted in the settings table via `Settings.put_secret/2`
and loaded into the appropriate Application config on startup. This keeps all
credentials in the portable SQLite database, encrypted via the Vault module.
The only external dependency is `SECRET_KEY_BASE` (used to derive encryption keys).
"""
alias SimpleshopTheme.Settings
require Logger
@doc """
Loads all secrets from the database into Application env.
Called at startup from the supervision tree, after the Repo is ready.
"""
def load_all do
load_stripe_config()
end
@doc """
Loads Stripe credentials from encrypted settings into Application env.
"""
def load_stripe_config do
api_key = Settings.get_secret("stripe_api_key")
signing_secret = Settings.get_secret("stripe_signing_secret")
if api_key do
Application.put_env(:stripity_stripe, :api_key, api_key)
Logger.debug("Stripe API key loaded from database")
end
if signing_secret do
Application.put_env(:stripity_stripe, :signing_secret, signing_secret)
Logger.debug("Stripe webhook secret loaded from database")
end
:ok
end
end

View File

@@ -6,6 +6,7 @@ defmodule SimpleshopTheme.Settings do
import Ecto.Query, warn: false
alias SimpleshopTheme.Repo
alias SimpleshopTheme.Settings.{Setting, ThemeSettings}
alias SimpleshopTheme.Vault
@doc """
Gets a setting by key with an optional default value.
@@ -115,8 +116,102 @@ defmodule SimpleshopTheme.Settings do
end
end
@doc """
Deletes a setting by key.
"""
def delete_setting(key) do
case Repo.get_by(Setting, key: key) do
nil -> :ok
setting -> Repo.delete(setting)
end
end
@doc """
Stores an encrypted secret in the database.
The plaintext is encrypted via Vault before storage.
"""
def put_secret(key, plaintext) when is_binary(plaintext) do
case Vault.encrypt(plaintext) do
{:ok, encrypted} ->
%Setting{key: key}
|> Setting.changeset(%{
key: key,
value: "[encrypted]",
value_type: "encrypted",
encrypted_value: encrypted
})
|> Repo.insert(
on_conflict: {:replace, [:value, :encrypted_value, :value_type, :updated_at]},
conflict_target: :key
)
{:error, reason} ->
{:error, reason}
end
end
@doc """
Retrieves and decrypts an encrypted secret from the database.
Returns the plaintext value or the default if not found.
"""
def get_secret(key, default \\ nil) do
case Repo.get_by(Setting, key: key) do
%Setting{value_type: "encrypted", encrypted_value: encrypted} when not is_nil(encrypted) ->
case Vault.decrypt(encrypted) do
{:ok, plaintext} -> plaintext
{:error, _} -> default
end
_ ->
default
end
end
@doc """
Checks whether an encrypted secret exists in the database.
"""
def has_secret?(key) do
case Repo.get_by(Setting, key: key) do
%Setting{value_type: "encrypted", encrypted_value: encrypted} when not is_nil(encrypted) ->
true
_ ->
false
end
end
@doc """
Returns a masked hint for an encrypted secret (e.g. "sk_test_•••abc").
Useful for admin UIs to confirm which key is active without exposing it.
"""
def secret_hint(key) do
case get_secret(key) do
nil ->
nil
plaintext when byte_size(plaintext) > 8 ->
prefix = binary_part(plaintext, 0, min(8, byte_size(plaintext)))
suffix = binary_part(plaintext, byte_size(plaintext), -3)
"#{prefix}•••#{suffix}"
_short ->
"•••"
end
end
# Private helpers
defp decode_value(%Setting{value_type: "encrypted", encrypted_value: encrypted})
when not is_nil(encrypted) do
case Vault.decrypt(encrypted) do
{:ok, plaintext} -> plaintext
{:error, _} -> nil
end
end
defp decode_value(%Setting{value: value, value_type: "json"}), do: Jason.decode!(value)
defp decode_value(%Setting{value: value, value_type: "integer"}), do: String.to_integer(value)

View File

@@ -9,6 +9,7 @@ defmodule SimpleshopTheme.Settings.Setting do
field :key, :string
field :value, :string
field :value_type, :string, default: "string"
field :encrypted_value, :binary
timestamps(type: :utc_datetime)
end
@@ -16,9 +17,22 @@ defmodule SimpleshopTheme.Settings.Setting do
@doc false
def changeset(setting, attrs) do
setting
|> cast(attrs, [:key, :value, :value_type])
|> validate_required([:key, :value, :value_type])
|> validate_inclusion(:value_type, ~w(string json integer boolean))
|> cast(attrs, [:key, :value, :value_type, :encrypted_value])
|> validate_required([:key, :value_type])
|> validate_inclusion(:value_type, ~w(string json integer boolean encrypted))
|> validate_has_value()
|> unique_constraint(:key)
end
# Encrypted settings store data in encrypted_value, not value.
# All other types require value.
defp validate_has_value(changeset) do
case get_field(changeset, :value_type) do
"encrypted" ->
validate_required(changeset, [:encrypted_value])
_ ->
validate_required(changeset, [:value])
end
end
end

View File

@@ -0,0 +1,135 @@
defmodule SimpleshopTheme.Stripe.Setup do
@moduledoc """
Handles Stripe account setup: key verification, automatic webhook
endpoint creation, and teardown.
"""
alias SimpleshopTheme.Settings
alias SimpleshopTheme.Secrets
require Logger
@webhook_events ["checkout.session.completed", "checkout.session.expired"]
@doc """
Verifies a Stripe API key by making a lightweight Balance API call.
"""
def verify_api_key(api_key) do
case Stripe.Balance.retrieve(%{}, api_key: api_key) do
{:ok, _balance} -> :ok
{:error, %Stripe.Error{message: message}} -> {:error, message}
{:error, _} -> {:error, "Could not connect to Stripe"}
end
end
@doc """
Full setup flow: verify key, store it, create webhook endpoint if possible.
Returns:
- `{:ok, :webhook_created}` — key valid, webhook auto-created (production)
- `{:ok, :localhost}` — key valid, but URL is localhost so webhook skipped
- `{:error, message}` — key invalid or setup failed
"""
def connect(api_key) do
with :ok <- verify_api_key(api_key) do
Settings.put_secret("stripe_api_key", api_key)
case maybe_create_webhook(api_key) do
{:ok, result} ->
Secrets.load_stripe_config()
{:ok, result}
{:error, reason} ->
# Key is valid and stored, but webhook creation failed.
# Still load the key so checkout works (webhooks can be set up manually).
Secrets.load_stripe_config()
{:error, reason}
end
end
end
@doc """
Removes Stripe configuration and deletes the webhook endpoint from Stripe.
"""
def disconnect do
delete_existing_webhook()
for key <- ["stripe_api_key", "stripe_signing_secret", "stripe_webhook_endpoint_id"] do
Settings.delete_setting(key)
end
Application.delete_env(:stripity_stripe, :api_key)
Application.delete_env(:stripity_stripe, :signing_secret)
:ok
end
@doc """
Saves a manually-provided webhook signing secret (for dev mode / Stripe CLI).
"""
def save_signing_secret(signing_secret) do
Settings.put_secret("stripe_signing_secret", signing_secret)
Secrets.load_stripe_config()
end
@doc """
Returns the webhook URL for this app.
"""
def webhook_url do
"#{SimpleshopThemeWeb.Endpoint.url()}/webhooks/stripe"
end
@doc """
Returns true if the app is running on localhost (Stripe can't reach it).
"""
def localhost? do
url = SimpleshopThemeWeb.Endpoint.url()
uri = URI.parse(url)
uri.host in ["localhost", "127.0.0.1", "0.0.0.0", "::1"]
end
defp maybe_create_webhook(api_key) do
if localhost?() do
{:ok, :localhost}
else
delete_existing_webhook()
create_webhook(api_key)
end
end
defp create_webhook(api_key) do
params = %{
url: webhook_url(),
enabled_events: @webhook_events
}
case Stripe.WebhookEndpoint.create(params, api_key: api_key) do
{:ok, endpoint} ->
Settings.put_secret("stripe_signing_secret", endpoint.secret)
Settings.put_setting("stripe_webhook_endpoint_id", endpoint.id, "string")
Logger.info("Stripe webhook endpoint created: #{endpoint.id}")
{:ok, :webhook_created}
{:error, %Stripe.Error{message: message}} ->
Logger.warning("Failed to create Stripe webhook: #{message}")
{:error, message}
{:error, _} ->
{:error, "Failed to create webhook endpoint"}
end
end
defp delete_existing_webhook do
endpoint_id = Settings.get_setting("stripe_webhook_endpoint_id")
api_key = Settings.get_secret("stripe_api_key")
if endpoint_id && api_key do
case Stripe.WebhookEndpoint.delete(endpoint_id, api_key: api_key) do
{:ok, _} ->
Logger.info("Deleted Stripe webhook endpoint: #{endpoint_id}")
{:error, reason} ->
Logger.warning("Failed to delete webhook endpoint #{endpoint_id}: #{inspect(reason)}")
end
end
end
end

View File

@@ -103,9 +103,17 @@ defmodule SimpleshopTheme.Theme.CSSCache do
write_concurrency: false
])
# Warm the cache on startup
warm()
{:ok, %{}, {:continue, :warm}}
end
{:ok, %{}}
@impl true
def handle_continue(:warm, state) do
try do
warm()
rescue
_ -> :ok
end
{:noreply, state}
end
end