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:
parent
ff1bc483b9
commit
eede9bb517
@ -86,8 +86,3 @@ config :phoenix_live_view,
|
|||||||
|
|
||||||
# Disable swoosh api client as it is only required for production adapters.
|
# Disable swoosh api client as it is only required for production adapters.
|
||||||
config :swoosh, :api_client, false
|
config :swoosh, :api_client, false
|
||||||
|
|
||||||
# Stripe test keys (set via environment variables)
|
|
||||||
config :stripity_stripe,
|
|
||||||
api_key: System.get_env("STRIPE_SECRET_KEY"),
|
|
||||||
signing_secret: System.get_env("STRIPE_WEBHOOK_SECRET")
|
|
||||||
|
|||||||
@ -113,12 +113,6 @@ if config_env() == :prod do
|
|||||||
#
|
#
|
||||||
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
|
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
|
||||||
|
|
||||||
# Stripe payment processing
|
# Stripe keys are stored encrypted in the database and loaded at runtime
|
||||||
config :stripity_stripe,
|
# by SimpleshopTheme.Secrets. No env vars needed.
|
||||||
api_key:
|
|
||||||
System.get_env("STRIPE_SECRET_KEY") ||
|
|
||||||
raise("Missing STRIPE_SECRET_KEY environment variable"),
|
|
||||||
signing_secret:
|
|
||||||
System.get_env("STRIPE_WEBHOOK_SECRET") ||
|
|
||||||
raise("Missing STRIPE_WEBHOOK_SECRET environment variable")
|
|
||||||
end
|
end
|
||||||
|
|||||||
@ -12,6 +12,8 @@ defmodule SimpleshopTheme.Application do
|
|||||||
SimpleshopTheme.Repo,
|
SimpleshopTheme.Repo,
|
||||||
{Ecto.Migrator,
|
{Ecto.Migrator,
|
||||||
repos: Application.fetch_env!(:simpleshop_theme, :ecto_repos), skip: skip_migrations?()},
|
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},
|
{DNSCluster, query: Application.get_env(:simpleshop_theme, :dns_cluster_query) || :ignore},
|
||||||
{Phoenix.PubSub, name: SimpleshopTheme.PubSub},
|
{Phoenix.PubSub, name: SimpleshopTheme.PubSub},
|
||||||
# Background job processing
|
# Background job processing
|
||||||
|
|||||||
44
lib/simpleshop_theme/secrets.ex
Normal file
44
lib/simpleshop_theme/secrets.ex
Normal 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
|
||||||
@ -6,6 +6,7 @@ defmodule SimpleshopTheme.Settings do
|
|||||||
import Ecto.Query, warn: false
|
import Ecto.Query, warn: false
|
||||||
alias SimpleshopTheme.Repo
|
alias SimpleshopTheme.Repo
|
||||||
alias SimpleshopTheme.Settings.{Setting, ThemeSettings}
|
alias SimpleshopTheme.Settings.{Setting, ThemeSettings}
|
||||||
|
alias SimpleshopTheme.Vault
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets a setting by key with an optional default value.
|
Gets a setting by key with an optional default value.
|
||||||
@ -115,8 +116,102 @@ defmodule SimpleshopTheme.Settings do
|
|||||||
end
|
end
|
||||||
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
|
# 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: "json"}), do: Jason.decode!(value)
|
||||||
defp decode_value(%Setting{value: value, value_type: "integer"}), do: String.to_integer(value)
|
defp decode_value(%Setting{value: value, value_type: "integer"}), do: String.to_integer(value)
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ defmodule SimpleshopTheme.Settings.Setting do
|
|||||||
field :key, :string
|
field :key, :string
|
||||||
field :value, :string
|
field :value, :string
|
||||||
field :value_type, :string, default: "string"
|
field :value_type, :string, default: "string"
|
||||||
|
field :encrypted_value, :binary
|
||||||
|
|
||||||
timestamps(type: :utc_datetime)
|
timestamps(type: :utc_datetime)
|
||||||
end
|
end
|
||||||
@ -16,9 +17,22 @@ defmodule SimpleshopTheme.Settings.Setting do
|
|||||||
@doc false
|
@doc false
|
||||||
def changeset(setting, attrs) do
|
def changeset(setting, attrs) do
|
||||||
setting
|
setting
|
||||||
|> cast(attrs, [:key, :value, :value_type])
|
|> cast(attrs, [:key, :value, :value_type, :encrypted_value])
|
||||||
|> validate_required([:key, :value, :value_type])
|
|> validate_required([:key, :value_type])
|
||||||
|> validate_inclusion(:value_type, ~w(string json integer boolean))
|
|> validate_inclusion(:value_type, ~w(string json integer boolean encrypted))
|
||||||
|
|> validate_has_value()
|
||||||
|> unique_constraint(:key)
|
|> unique_constraint(:key)
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
135
lib/simpleshop_theme/stripe/setup.ex
Normal file
135
lib/simpleshop_theme/stripe/setup.ex
Normal 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
|
||||||
@ -103,9 +103,17 @@ defmodule SimpleshopTheme.Theme.CSSCache do
|
|||||||
write_concurrency: false
|
write_concurrency: false
|
||||||
])
|
])
|
||||||
|
|
||||||
# Warm the cache on startup
|
{:ok, %{}, {:continue, :warm}}
|
||||||
warm()
|
end
|
||||||
|
|
||||||
{:ok, %{}}
|
@impl true
|
||||||
|
def handle_continue(:warm, state) do
|
||||||
|
try do
|
||||||
|
warm()
|
||||||
|
rescue
|
||||||
|
_ -> :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, state}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -45,6 +45,9 @@
|
|||||||
<li>
|
<li>
|
||||||
<.link href={~p"/admin/theme"}>Theme</.link>
|
<.link href={~p"/admin/theme"}>Theme</.link>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<.link href={~p"/admin/settings"}>Credentials</.link>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<.link href={~p"/users/settings"}>Settings</.link>
|
<.link href={~p"/users/settings"}>Settings</.link>
|
||||||
</li>
|
</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", ProviderLive.Index, :index
|
||||||
live "/admin/providers/new", ProviderLive.Form, :new
|
live "/admin/providers/new", ProviderLive.Form, :new
|
||||||
live "/admin/providers/:id/edit", ProviderLive.Form, :edit
|
live "/admin/providers/:id/edit", ProviderLive.Form, :edit
|
||||||
|
live "/admin/settings", AdminLive.Settings, :index
|
||||||
end
|
end
|
||||||
|
|
||||||
post "/users/update-password", UserSessionController, :update_password
|
post "/users/update-password", UserSessionController, :update_password
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
defmodule SimpleshopTheme.Repo.Migrations.AddEncryptedValueToSettings do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:settings) do
|
||||||
|
add :encrypted_value, :binary
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -133,4 +133,76 @@ defmodule SimpleshopTheme.SettingsTest do
|
|||||||
assert {:error, :preset_not_found} = Settings.apply_preset(:nonexistent)
|
assert {:error, :preset_not_found} = Settings.apply_preset(:nonexistent)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "put_secret/2 and get_secret/2" do
|
||||||
|
test "encrypts, stores, and retrieves a secret" do
|
||||||
|
assert {:ok, _} = Settings.put_secret("test_key", "super_secret_value")
|
||||||
|
assert Settings.get_secret("test_key") == "super_secret_value"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns default when secret doesn't exist" do
|
||||||
|
assert Settings.get_secret("nonexistent") == nil
|
||||||
|
assert Settings.get_secret("nonexistent", "fallback") == "fallback"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "upserts existing secret" do
|
||||||
|
assert {:ok, _} = Settings.put_secret("test_key", "first_value")
|
||||||
|
assert {:ok, _} = Settings.put_secret("test_key", "second_value")
|
||||||
|
assert Settings.get_secret("test_key") == "second_value"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "stores encrypted_value as binary, not plaintext" do
|
||||||
|
{:ok, _} = Settings.put_secret("test_key", "plaintext_here")
|
||||||
|
|
||||||
|
setting = Repo.get_by(SimpleshopTheme.Settings.Setting, key: "test_key")
|
||||||
|
assert setting.value_type == "encrypted"
|
||||||
|
assert setting.value == "[encrypted]"
|
||||||
|
assert is_binary(setting.encrypted_value)
|
||||||
|
refute setting.encrypted_value == "plaintext_here"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "has_secret?/1" do
|
||||||
|
test "returns false when secret doesn't exist" do
|
||||||
|
refute Settings.has_secret?("nonexistent")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns true when secret exists" do
|
||||||
|
{:ok, _} = Settings.put_secret("test_key", "value")
|
||||||
|
assert Settings.has_secret?("test_key")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "secret_hint/1" do
|
||||||
|
test "returns nil when secret doesn't exist" do
|
||||||
|
assert Settings.secret_hint("nonexistent") == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns masked hint for long secrets" do
|
||||||
|
{:ok, _} = Settings.put_secret("test_key", "sk_test_abc123xyz789")
|
||||||
|
hint = Settings.secret_hint("test_key")
|
||||||
|
assert hint =~ "sk_test_"
|
||||||
|
assert hint =~ "•••"
|
||||||
|
assert hint =~ "789"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns masked hint for short secrets" do
|
||||||
|
{:ok, _} = Settings.put_secret("test_key", "short")
|
||||||
|
assert Settings.secret_hint("test_key") == "•••"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "delete_setting/1" do
|
||||||
|
test "deletes an existing setting" do
|
||||||
|
{:ok, _} = Settings.put_setting("to_delete", "value")
|
||||||
|
assert Settings.get_setting("to_delete") == "value"
|
||||||
|
|
||||||
|
assert {:ok, _} = Settings.delete_setting("to_delete")
|
||||||
|
assert Settings.get_setting("to_delete") == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns :ok when setting doesn't exist" do
|
||||||
|
assert :ok = Settings.delete_setting("nonexistent")
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
55
test/simpleshop_theme/stripe/setup_test.exs
Normal file
55
test/simpleshop_theme/stripe/setup_test.exs
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
defmodule SimpleshopTheme.Stripe.SetupTest do
|
||||||
|
use SimpleshopTheme.DataCase, async: false
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Settings
|
||||||
|
alias SimpleshopTheme.Stripe.Setup
|
||||||
|
|
||||||
|
describe "localhost?/0" do
|
||||||
|
test "returns true for localhost endpoint" do
|
||||||
|
# In test env, endpoint URL is localhost
|
||||||
|
assert Setup.localhost?()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "webhook_url/0" do
|
||||||
|
test "returns the webhook endpoint URL" do
|
||||||
|
url = Setup.webhook_url()
|
||||||
|
assert url =~ "/webhooks/stripe"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "save_signing_secret/1" do
|
||||||
|
test "stores signing secret and loads into Application env" do
|
||||||
|
Setup.save_signing_secret("whsec_test_secret_123")
|
||||||
|
|
||||||
|
assert Settings.get_secret("stripe_signing_secret") == "whsec_test_secret_123"
|
||||||
|
assert Application.get_env(:stripity_stripe, :signing_secret) == "whsec_test_secret_123"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "disconnect/0" do
|
||||||
|
test "removes all Stripe settings from DB and Application env" do
|
||||||
|
# Set up some Stripe config
|
||||||
|
Settings.put_secret("stripe_api_key", "sk_test_123")
|
||||||
|
Settings.put_secret("stripe_signing_secret", "whsec_test_456")
|
||||||
|
Settings.put_setting("stripe_webhook_endpoint_id", "we_test_789")
|
||||||
|
Application.put_env(:stripity_stripe, :api_key, "sk_test_123")
|
||||||
|
Application.put_env(:stripity_stripe, :signing_secret, "whsec_test_456")
|
||||||
|
|
||||||
|
assert :ok = Setup.disconnect()
|
||||||
|
|
||||||
|
# DB cleared
|
||||||
|
refute Settings.has_secret?("stripe_api_key")
|
||||||
|
refute Settings.has_secret?("stripe_signing_secret")
|
||||||
|
assert Settings.get_setting("stripe_webhook_endpoint_id") == nil
|
||||||
|
|
||||||
|
# Application env cleared
|
||||||
|
assert Application.get_env(:stripity_stripe, :api_key) == nil
|
||||||
|
assert Application.get_env(:stripity_stripe, :signing_secret) == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles disconnect when nothing is configured" do
|
||||||
|
assert :ok = Setup.disconnect()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
100
test/simpleshop_theme_web/live/admin_live/settings_test.exs
Normal file
100
test/simpleshop_theme_web/live/admin_live/settings_test.exs
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
defmodule SimpleshopThemeWeb.AdminLive.SettingsTest do
|
||||||
|
use SimpleshopThemeWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
import SimpleshopTheme.AccountsFixtures
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Settings
|
||||||
|
|
||||||
|
setup do
|
||||||
|
user = user_fixture()
|
||||||
|
%{user: user}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "unauthenticated" do
|
||||||
|
test "redirects to login", %{conn: conn} do
|
||||||
|
{:error, redirect} = live(conn, ~p"/admin/settings")
|
||||||
|
assert {:redirect, %{to: path}} = redirect
|
||||||
|
assert path == ~p"/users/log-in"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "authenticated - not configured" do
|
||||||
|
setup %{conn: conn, user: user} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
%{conn: conn}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders setup form when Stripe is not configured", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/admin/settings")
|
||||||
|
|
||||||
|
assert html =~ "Credentials"
|
||||||
|
assert html =~ "Not connected"
|
||||||
|
assert html =~ "Connect Stripe"
|
||||||
|
assert html =~ "Stripe dashboard"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows error for empty API key", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/settings")
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> form("form", %{stripe: %{api_key: ""}})
|
||||||
|
|> render_submit()
|
||||||
|
|
||||||
|
assert html =~ "Please enter your Stripe secret key"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "authenticated - connected (localhost)" do
|
||||||
|
setup %{conn: conn, user: user} do
|
||||||
|
# Pre-configure a Stripe API key
|
||||||
|
Settings.put_secret("stripe_api_key", "sk_test_simulated_key_12345")
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
%{conn: conn}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders dev mode view with CLI instructions", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/admin/settings")
|
||||||
|
|
||||||
|
assert html =~ "Dev mode"
|
||||||
|
assert html =~ "sk_test_•••345"
|
||||||
|
assert html =~ "stripe listen"
|
||||||
|
assert html =~ "Webhook signing secret"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "saves manual signing secret", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/settings")
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> form("form", %{webhook: %{signing_secret: "whsec_test_manual_456"}})
|
||||||
|
|> render_submit()
|
||||||
|
|
||||||
|
assert html =~ "Webhook signing secret saved"
|
||||||
|
assert html =~ "whsec_te•••456"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows error for empty signing secret", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/settings")
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> form("form", %{webhook: %{signing_secret: ""}})
|
||||||
|
|> render_submit()
|
||||||
|
|
||||||
|
assert html =~ "Please enter a signing secret"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "disconnect clears configuration", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/settings")
|
||||||
|
|
||||||
|
html = render_click(view, "disconnect_stripe")
|
||||||
|
|
||||||
|
assert html =~ "Stripe disconnected"
|
||||||
|
assert html =~ "Not connected"
|
||||||
|
assert html =~ "Connect Stripe"
|
||||||
|
refute Settings.has_secret?("stripe_api_key")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in New Issue
Block a user