berrypod/lib/berrypod_web/live/admin/settings.ex

726 lines
24 KiB
Elixir
Raw Normal View History

defmodule BerrypodWeb.Admin.Settings do
use BerrypodWeb, :live_view
alias Berrypod.Accounts
alias Berrypod.Products
alias Berrypod.Settings
alias Berrypod.Stripe.Setup, as: StripeSetup
@impl true
def mount(_params, _session, socket) do
user = socket.assigns.current_scope.user
{:ok,
socket
|> assign(:page_title, "Settings")
|> assign(:site_live, Settings.site_live?())
|> assign(:cart_recovery_enabled, Settings.abandoned_cart_recovery_enabled?())
|> assign_stripe_state()
|> assign_products_state()
|> assign_account_state(user)}
end
# -- Stripe assigns --
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(:stripe_advanced_open, false)
|> assign(:connecting, false)
end
# -- Products assigns --
defp assign_products_state(socket) do
connections = Products.list_provider_connections()
connection_info =
case connections do
[conn | _] ->
product_count = Products.count_products_for_connection(conn.id)
%{connection: conn, product_count: product_count}
[] ->
nil
end
assign(socket, :provider, connection_info)
end
# -- Account assigns --
defp assign_account_state(socket, user) do
email_changeset = Accounts.change_user_email(user, %{}, validate_unique: false)
password_changeset = Accounts.change_user_password(user, %{}, hash_password: false)
socket
|> assign(:current_email, user.email)
|> assign(:email_form, to_form(email_changeset))
|> assign(:password_form, to_form(password_changeset))
|> assign(:trigger_submit, false)
end
# -- Events: shop status --
@impl true
def handle_event("toggle_site_live", _params, socket) do
new_value = !socket.assigns.site_live
{:ok, _} = Settings.set_site_live(new_value)
message = if new_value, do: "Shop is now live", else: "Shop taken offline"
{:noreply,
socket
|> assign(:site_live, new_value)
|> put_flash(:info, message)}
end
# -- Events: cart recovery --
def handle_event("toggle_cart_recovery", _params, socket) do
new_value = !socket.assigns.cart_recovery_enabled
{:ok, _} = Settings.set_abandoned_cart_recovery(new_value)
message =
if new_value,
do: "Cart recovery emails enabled",
else: "Cart recovery emails disabled"
{:noreply,
socket
|> assign(:cart_recovery_enabled, new_value)
|> put_flash(:info, message)}
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, :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_stripe_advanced", _params, socket) do
{:noreply, assign(socket, :stripe_advanced_open, !socket.assigns.stripe_advanced_open)}
end
# -- Events: products --
def handle_event("sync", %{"id" => id}, socket) do
connection = Products.get_provider_connection!(id)
case Products.enqueue_sync(connection) do
{:ok, _job} ->
{:noreply,
socket
|> assign_products_state()
|> put_flash(:info, "Sync started for #{connection.name}")}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Failed to start sync")}
end
end
def handle_event("delete_connection", %{"id" => id}, socket) do
connection = Products.get_provider_connection!(id)
{:ok, _} = Products.delete_provider_connection(connection)
{:noreply,
socket
|> assign_products_state()
|> put_flash(:info, "Provider connection deleted")}
end
# -- Events: account --
def handle_event("validate_email", %{"user" => user_params}, socket) do
email_form =
socket.assigns.current_scope.user
|> Accounts.change_user_email(user_params, validate_unique: false)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, email_form: email_form)}
end
def handle_event("update_email", %{"user" => user_params}, socket) do
user = socket.assigns.current_scope.user
unless Accounts.sudo_mode?(user) do
{:noreply,
socket
|> put_flash(:error, "Please log in again to change account settings.")
|> redirect(to: ~p"/users/log-in")}
else
case Accounts.change_user_email(user, user_params) do
%{valid?: true} = changeset ->
Accounts.deliver_user_update_email_instructions(
Ecto.Changeset.apply_action!(changeset, :insert),
user.email,
&url(~p"/users/settings/confirm-email/#{&1}")
)
info = "A link to confirm your email change has been sent to the new address."
{:noreply, put_flash(socket, :info, info)}
changeset ->
{:noreply, assign(socket, :email_form, to_form(changeset, action: :insert))}
end
end
end
def handle_event("validate_password", %{"user" => user_params}, socket) do
password_form =
socket.assigns.current_scope.user
|> Accounts.change_user_password(user_params, hash_password: false)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, password_form: password_form)}
end
def handle_event("update_password", %{"user" => user_params}, socket) do
user = socket.assigns.current_scope.user
unless Accounts.sudo_mode?(user) do
{:noreply,
socket
|> put_flash(:error, "Please log in again to change account settings.")
|> redirect(to: ~p"/users/log-in")}
else
case Accounts.change_user_password(user, user_params) do
%{valid?: true} = changeset ->
{:noreply, assign(socket, trigger_submit: true, password_form: to_form(changeset))}
changeset ->
{:noreply, assign(socket, password_form: to_form(changeset, action: :insert))}
end
end
end
# -- Render --
@impl true
def render(assigns) do
~H"""
<div class="admin-settings">
<.header>
Settings
</.header>
<%!-- Shop status --%>
<section class="admin-section">
<div class="admin-section-header">
<h2 class="admin-section-title">Shop status</h2>
<%= if @site_live do %>
<.status_pill color="green">
<.icon name="hero-check-circle-mini" class="size-3" /> Live
</.status_pill>
<% else %>
<.status_pill color="zinc">Offline</.status_pill>
<% end %>
</div>
<p class="admin-section-desc">
<%= if @site_live do %>
Your shop is visible to the public.
<% else %>
Your shop is offline. Visitors see a "coming soon" page.
<% end %>
</p>
<div class="admin-section-body">
<button
phx-click="toggle_site_live"
class={[
"admin-btn admin-btn-sm",
if(@site_live, do: "admin-btn-outline", else: "admin-btn-primary")
]}
>
<%= if @site_live do %>
<.icon name="hero-eye-slash-mini" class="size-4" /> Take offline
<% else %>
<.icon name="hero-eye-mini" class="size-4" /> Go live
<% end %>
</button>
</div>
</section>
<%!-- Payments --%>
<section class="admin-section">
<div class="admin-section-header">
<h2 class="admin-section-title">Payments</h2>
<%= case @stripe_status do %>
<% :connected -> %>
<.status_pill color="green">
<.icon name="hero-check-circle-mini" class="size-3" /> Connected
</.status_pill>
<% :connected_localhost -> %>
<.status_pill color="amber">
<.icon name="hero-exclamation-triangle-mini" class="size-3" /> Dev mode
</.status_pill>
<% :not_configured -> %>
<.status_pill color="zinc">Not connected</.status_pill>
<% 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={@stripe_advanced_open}
/>
<% end %>
</section>
<%!-- Products --%>
<section class="admin-section">
<div class="admin-section-header">
<h2 class="admin-section-title">Products</h2>
<%= if @provider do %>
<.status_pill color="green">
<.icon name="hero-check-circle-mini" class="size-3" /> Connected
</.status_pill>
<% else %>
<.status_pill color="zinc">Not connected</.status_pill>
<% end %>
</div>
<%= if @provider do %>
<.provider_connected provider={@provider} />
<% else %>
<div class="admin-section-body">
<p class="admin-section-desc" style="margin-top: 0;">
Connect a print-on-demand provider to import products into your shop.
</p>
<div class="admin-section-body">
<.link navigate={~p"/admin/providers"} class="admin-btn admin-btn-primary admin-btn-sm">
<.icon name="hero-plus-mini" class="size-4" /> Connect a provider
</.link>
</div>
</div>
<% end %>
</section>
<%!-- Cart recovery --%>
<section class="admin-section">
<div class="admin-section-header">
<h2 class="admin-section-title">Cart recovery</h2>
<%= if @cart_recovery_enabled do %>
<.status_pill color="green">
<.icon name="hero-check-circle-mini" class="size-3" /> On
</.status_pill>
<% else %>
<.status_pill color="zinc">Off</.status_pill>
<% end %>
</div>
<p class="admin-section-desc">
When on, customers who entered their email at Stripe checkout but didn't complete
payment receive a single plain-text recovery email one hour later.
No tracking pixels. One email, never more.
</p>
<%= if @cart_recovery_enabled do %>
<p class="admin-section-desc" style="color: #b45309;">
Make sure your privacy policy mentions that a single recovery email may be sent,
and that customers can unsubscribe at any time.
</p>
<% end %>
<div class="admin-section-body">
<button
phx-click="toggle_cart_recovery"
class={[
"admin-btn admin-btn-sm",
if(@cart_recovery_enabled, do: "admin-btn-outline", else: "admin-btn-primary")
]}
>
{if @cart_recovery_enabled, do: "Turn off", else: "Turn on"}
</button>
</div>
</section>
<%!-- Account --%>
<section class="admin-section">
<h2 class="admin-section-title">Account</h2>
<div class="admin-stack" style="margin-top: 1rem; --admin-stack-gap: 1.5rem;">
<.form
for={@email_form}
id="email_form"
phx-submit="update_email"
phx-change="validate_email"
>
<.input
field={@email_form[:email]}
type="email"
label="Email"
autocomplete="username"
required
/>
<div style="margin-top: 0.75rem;">
<.button phx-disable-with="Saving...">Change email</.button>
</div>
</.form>
<div style="border-top: 1px solid var(--t-surface-sunken); padding-top: 1.5rem;">
<.form
for={@password_form}
id="password_form"
action={~p"/users/update-password"}
method="post"
phx-change="validate_password"
phx-submit="update_password"
phx-trigger-action={@trigger_submit}
>
<input
name={@password_form[:email].name}
type="hidden"
id="hidden_user_email"
autocomplete="username"
value={@current_email}
/>
<.input
field={@password_form[:password]}
type="password"
label="New password"
autocomplete="new-password"
required
/>
<.input
field={@password_form[:password_confirmation]}
type="password"
label="Confirm new password"
autocomplete="new-password"
/>
<div style="margin-top: 0.75rem;">
<.button phx-disable-with="Saving...">Change password</.button>
</div>
</.form>
</div>
</div>
</section>
<%!-- Advanced --%>
<section class="admin-section" style="padding-bottom: 2.5rem;">
<h2 class="admin-section-title">Advanced</h2>
<div class="admin-stack" style="margin-top: 1rem; --admin-stack-gap: 0.5rem;">
<.link href={~p"/admin/dashboard"} class="admin-link-subtle">
<.icon name="hero-chart-bar" class="size-4 inline" /> System dashboard
</.link>
<.link href={~p"/admin/errors"} class="admin-link-subtle">
<.icon name="hero-bug-ant" class="size-4 inline" /> Error tracker
</.link>
</div>
</section>
</div>
"""
end
# -- Function components --
attr :color, :string, required: true
slot :inner_block, required: true
defp status_pill(assigns) do
modifier =
case assigns.color do
"green" -> "admin-status-pill-green"
"amber" -> "admin-status-pill-amber"
_ -> "admin-status-pill-zinc"
end
assigns = assign(assigns, :modifier, modifier)
~H"""
<span class={["admin-status-pill", @modifier]}>
{render_slot(@inner_block)}
</span>
"""
end
attr :provider, :map, required: true
defp provider_connected(assigns) do
conn = assigns.provider.connection
assigns =
assigns
|> assign(:connection, conn)
|> assign(:product_count, assigns.provider.product_count)
|> assign(:syncing, conn.sync_status == "syncing")
|> assign(:provider_label, String.capitalize(conn.provider_type))
~H"""
<div class="admin-section-body">
<dl class="admin-dl">
<div class="admin-dl-row">
<dt class="admin-dl-term">Provider</dt>
<dd class="admin-dl-value">{@provider_label}</dd>
</div>
<div class="admin-dl-row">
<dt class="admin-dl-term">Shop</dt>
<dd class="admin-dl-value">{@connection.name}</dd>
</div>
<div class="admin-dl-row">
<dt class="admin-dl-term">Products</dt>
<dd class="admin-dl-value">{@product_count}</dd>
</div>
<div class="admin-dl-row">
<dt class="admin-dl-term">Last synced</dt>
<dd class="admin-dl-value">
<%= if @connection.last_synced_at do %>
{format_relative_time(@connection.last_synced_at)}
<% else %>
<span style="color: #d97706;">Never</span>
<% end %>
</dd>
</div>
</dl>
<div class="admin-cluster" style="margin-top: 1rem;">
<button
phx-click="sync"
phx-value-id={@connection.id}
disabled={@syncing}
class="admin-btn admin-btn-outline admin-btn-sm"
>
<.icon
name="hero-arrow-path"
class={if @syncing, do: "size-4 animate-spin", else: "size-4"}
/>
{if @syncing, do: "Syncing...", else: "Sync products"}
</button>
<.link
navigate={~p"/admin/providers/#{@connection.id}/edit"}
class="admin-btn admin-btn-outline admin-btn-sm"
>
<.icon name="hero-cog-6-tooth" class="size-4" /> Settings
</.link>
<button
phx-click="delete_connection"
phx-value-id={@connection.id}
data-confirm={"Disconnect from #{@provider_label}? Your synced products will remain in your shop."}
class="admin-link-danger"
style="padding: 0.375rem 0.5rem;"
>
Disconnect
</button>
</div>
</div>
"""
end
defp stripe_setup_form(assigns) do
~H"""
<div class="admin-section-body">
<p class="admin-section-desc" style="margin-top: 0;">
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="admin-link">
Stripe dashboard
</a>
under Developers &rarr; API keys.
</p>
<.form for={@connect_form} phx-submit="connect_stripe" style="margin-top: 1.5rem;">
<.input
field={@connect_form[:api_key]}
type="password"
label="Secret key"
autocomplete="off"
placeholder="sk_test_... or sk_live_..."
/>
<p class="admin-text-tertiary" style="margin-top: 0.25rem;">
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="admin-section-body">
<.button phx-disable-with="Connecting...">
Connect Stripe
</.button>
</div>
</.form>
</div>
"""
end
defp stripe_connected_view(assigns) do
~H"""
<div class="admin-stack" style="margin-top: 1rem;">
<dl class="admin-dl">
<div class="admin-dl-row">
<dt class="admin-dl-term">API key</dt>
<dd class="admin-dl-value"><code>{@stripe_api_key_hint}</code></dd>
</div>
<div class="admin-dl-row">
<dt class="admin-dl-term">Webhook URL</dt>
<dd class="admin-dl-value"><code style="font-size: 0.75rem; word-break: break-all;">{@stripe_webhook_url}</code></dd>
</div>
<div class="admin-dl-row">
<dt class="admin-dl-term">Webhook secret</dt>
<dd class="admin-dl-value">
<%= if @stripe_has_signing_secret do %>
<code>{@stripe_signing_secret_hint}</code>
<% else %>
<span style="color: #d97706;">Not set</span>
<% end %>
</dd>
</div>
</dl>
<%= if @stripe_status == :connected_localhost do %>
<div class="admin-info-box admin-info-box-amber">
<p>
Stripe can't reach localhost for webhooks. For local testing, run the Stripe CLI:
</p>
<pre>stripe listen --forward-to localhost:4000/webhooks/stripe</pre>
<p style="margin-top: 0.5rem; font-size: 0.75rem; color: #b45309;">
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" style="margin-top: 0.5rem;">
<.input
field={@secret_form[:signing_secret]}
type="password"
label="Webhook signing secret"
autocomplete="off"
placeholder="whsec_..."
/>
<div style="margin-top: 0.75rem;">
<.button phx-disable-with="Saving...">Save signing secret</.button>
</div>
</.form>
<% else %>
<div style="border-top: 1px solid var(--t-surface-sunken); padding-top: 0.75rem;">
<button phx-click="toggle_stripe_advanced" class="admin-link-subtle admin-row" style="--admin-row-gap: 0.25rem;">
<.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 style="margin-top: 0.75rem;">
<p class="admin-text-tertiary" style="margin-bottom: 0.75rem;">
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 style="margin-top: 0.75rem;">
<.button phx-disable-with="Saving...">Save signing secret</.button>
</div>
</.form>
</div>
<% end %>
</div>
<% end %>
<div style="border-top: 1px solid var(--t-surface-sunken); padding-top: 1rem;">
<button
phx-click="disconnect_stripe"
data-confirm="This will remove your Stripe API key and delete the webhook endpoint. Are you sure?"
class="admin-link-danger"
>
Disconnect Stripe
</button>
</div>
</div>
"""
end
defp format_relative_time(datetime) do
diff = DateTime.diff(DateTime.utc_now(), datetime, :second)
cond do
diff < 60 -> "just now"
diff < 3600 -> "#{div(diff, 60)} min ago"
diff < 86400 -> "#{div(diff, 3600)} hours ago"
true -> "#{div(diff, 86400)} days ago"
end
end
end